@formancy/data-sqlserver
v0.3.0
Published
The Microsoft SQL Server adapter for Formancy Data: catalog inspection, quoting, binding, transactions and error translation, behind the port @formancy/data-core defines.
Readme
@formancy/data-sqlserver
The Microsoft SQL Server adapter for Formancy Data, behind the port
@formancy/data-core defines. Built on mssql
over the pure-JavaScript tedious driver, so there is no native module and no
ODBC to install.
What is here today
An adapter that reaches a real server and says which version answered:
import { connectSqlServer, createSqlServerAdapter } from '@formancy/data-sqlserver'
const pool = await connectSqlServer({ host, port, database, user, password, encrypt: true, trustServerCertificate: false })
const adapter = createSqlServerAdapter(pool)
await adapter.ping() // { kind: 'sqlserver', version: '16.0.4205.1', product: 'sqlserver', major: 16, compatibility: 160 }
await adapter.close()A SQL login with encrypt: true is never sent to a server that did not
say it encrypts. It goes over TDS 7, which every SQL Server image the
suites run takes: the client's PRELOGIN and the server's answer cross in
the clear, and tedious 20.3.3 starts TLS only when the answer says ON or
REQ, and otherwise sends the login unencrypted. So the pool is given a
connector of this package's own (src/encryption.ts), which opens each TCP
connection as tedious's connectInSequence does and puts a stream in
front of it that withholds the server's first message until it has read
the whole of it. ON or REQ: it, and everything after it, passes unchanged.
OFF or NOT_SUP: the connection ends before the login is sent, and
connectSqlServer rejects with "The server answered that it would not
encrypt the connection, so the login was not sent." and code EENCRYPT;
an answer it cannot read is refused in a sentence of its own. Neither names
the user or the password. With encrypt: false there is no guard: the
login crosses unencrypted, as the caller chose. With
trustServerCertificate: true the guard sees that TLS starts, not with
whom
(0081).
Or as a Microsoft Entra principal, a managed identity or a service principal, where the server is Azure SQL and takes Entra logins:
const pool = await connectSqlServer({ host, port, database, encrypt: true, trustServerCertificate: false, entra: { type: 'managed-identity', clientId } })
// or entra: { type: 'service-principal', tenantId, clientId, clientSecret }No test of this release logs in as either, which SQLSERVER_LOGINS
says and the README's tested-on table derives from it: no container takes
an Entra login, and no test asks Entra. An Entra pool is TDS 8.0,
encrypt: 'strict' whatever encrypt: true would otherwise mean: TLS
before the first TDS message, with the server's certificate checked, so
nothing of the login crosses before a handshake with a server the host
trusts. The server must take TDS 8.0; of the images measured on
2026-10-17, SQL Server 2025 with a certificate configured did, and 2019
and 2022 on Linux reset the connection. An Entra principal without
encrypt, or with trustServerCertificate, is refused before anything
is opened. The pool is given one credential for the entra object it was
opened with -- open another pool with the same object and it gets the
same credential and the token it caches -- which asks Entra for Azure
SQL's token, https://database.windows.net/.default, and refuses any
other resource a server names. A service principal's authority is the
public cloud's, written down, so AZURE_AUTHORITY_HOST is never read. A
failed login rejects with a sentence, with Entra's AADSTS code when it
gave one, that names no tenant, client or secret. The options are plain strings, so no
@azure type is in this package's declarations; @azure/identity is its
dependency at exactly 4.13.3, the copy tedious already installs
(0074).
Open the pool with connectSqlServer, not with an mssql you imported
yourself. This package binds every value with mssql.NVarChar and friends from
its own copy of mssql, and tedious checks those by identity: a pool from
another copy refuses every parameter with "type.validate is not a function".
pnpm produced exactly that for the server, two installs of the same mssql
version, and only the end-to-end suite saw it
(0025).
And discovery: what a connection can see of the tables and views in an
approved scope, as a MetadataSnapshot built by @formancy/data-core's
createSnapshot, with what it could not see written down as gaps
(0007):
import { discoverSqlServer } from '@formancy/data-sqlserver'
const snapshot = await discoverSqlServer(pool, { schemas: ['sales'] })
snapshot.objects // tables and views, columns with normalised types and access, keys, foreign keys, checks, row security
snapshot.account // { user: 'dbo', login: 'sa' }
snapshot.gaps // e.g. on sales.order: "fk_order_customer references a table this account cannot see: …"It reads SQL Server's catalog views, one concern per file under
src/discovery/: objects, columns and their types, what the account may do
with each column, keys, foreign keys, checks, security policies, the account,
and what the account cannot see. Before any of them it reads the server, as
ping does, and refuses a server whose catalog has no sys.edge_constraints
-- every SQL Server before 2019 -- naming the release it answered: "SQL
Server 2017 (14) cannot be discovered: this adapter reads
sys.edge_constraints, which SQL Server has from 2019 (15)." The view decides
and not the major, because Azure SQL Database answers 12, which a boxed
server answers as 2014, and has the view. The sentence and its condition are
unit-tested; no committed test starts a server without the view
(0068).
The scope's schema names are bound as
parameters and matched by the database's own collation. Discovery is not one
consistent read: a concurrent ALTER can tear it, and createSnapshot then
refuses what no catalog could have produced. A column and its privileges are
read in one statement, so no column lacks an answer; but the privilege check
looks a column up by name when it runs, so a concurrent rename can answer
"not granted" for a column that is, until the next snapshot.
An answer lost after a write. records-lost-answer.integration.test.ts
puts a TCP hop in front of the server that drops its answer, so every byte is
tedious's and the server's and only the network fails (0031). What it proves
on SQL Server 2022, with the driver versions Tests names:
- An insert or an update whose answer is lost after it committed is
unknown-outcome. The write crossed the hop once and is stored once; an update's change is stored and its rowversion has moved past the one sent, so saving again with that version isstale, not a second write. - A request timeout after the commit is
unknown-outcometoo, and the row is stored: tedious gives up atrequestTimeout, and again atcancelTimeoutwhen the answer to its cancel is lost as well. The suite'srequestTimeoutis five seconds, against a commit whose answer reached the hop 40 ms after the send, because an attention that reached the server before the commit would cancel the batch and prove nothing. On PostgreSQL a server-sidestatement_timeoutrolls back and isunavailable; here the timeout is the client's, and says nothing about the commit. - A socket cut while the write waits on a lock is
unknown-outcome, which over-reports: SQL Server ends a session whose client went away, the session is gone shortly after, and once the lock is released nothing was stored. PostgreSQL does the opposite, and an orphaned write there commits after the release. - What the driver rejects a lost answer with is a
RequestErrorwhosenumberis the string'ECONNRESET'for a cut socket and'ETIMEOUT'for a timeout, with noclass: mssql copies a driver error'scodeintonumberwhen it has noinfo. That every failure after a connection is handed out is aRequestErrorwas read in mssql's source for 0017; it is now tested, for a socket and a timeout. It is whyserverErroraskstypeof number === 'number': a lost answer read as the server's refusal would berefused.
What an account needs. For a snapshot with no gap, VIEW DEFINITION on
the database, and nothing else: no SELECT, no data access
(0027).
VIEW DEFINITION on each schema in scope describes every table and view the
owner sees, but not every security policy: a policy lives in a schema of its
own and can filter a table in any other, so row security is then unknown
except where a visible policy applies, and one scope gap says why.
VIEW SECURITY DEFINITION does not help; it lists no policy. Nor does a
schema grant show a foreign key on a table of another schema that refers to
one in scope, so what refers to each table is then a referenced-by scope
gap too, by the same rule
(0049);
whether the account may DELETE from a table is HAS_PERMS_BY_NAME's answer
on the object, read for every object it lists. A graph node table that an
edge constraint connects carries a referenced-by gap under any grant, the
owner's included: an edge constraint is not a foreign key and is not
listed, yet a delete of a node removes the edges a CASCADE one connects,
and a NO ACTION one refuses it. Without either
grant, SQL Server lists only the objects the account holds a permission on and
returns NULL for every default and check definition, and the snapshot says so
per object and per schema rather than looking complete. A table someone has
denied VIEW DEFINITION or CONTROL on, whether to the account or to a role
it is in, vanishes even under that grant; the account can count those denials,
though not place them, and the snapshot says that too. Any such deny, on an
object or on a schema, leaves row security unknown even under the database
grant, because the hidden thing may be a policy. Only a deny the server
applies counts: one made to public binds no sysadmin and no dbo, so the
owner's snapshot stays complete.
What the snapshot says the account may do. Every column carries access:
whether the account may SELECT it, name it in an INSERT and in an
UPDATE, as HAS_PERMS_BY_NAME answers for the connected user, through every
role it is in. SQL Server grants INSERT on whole objects only, so every column
of an object the account may insert into says yes. A column denied SELECT
says so, where before only the first read found out. rowSecurity is
applies where an enabled security policy targets the object — SQL Server
exempts nobody, dbo included — none where the database grant establishes
that none does, and unknown otherwise. The snapshot names its account:
USER_NAME(), which the fingerprint carries, and ORIGINAL_LOGIN(), which it
does not; sa and any other sysadmin are dbo. Each is what was true at
discovery: a grant revoked afterwards still fails at runtime as
permission-denied.
What a column is read as
(0026).
A text length is in the unit the column counts: UTF-16 code units for
nchar/nvarchar under every collation, a UTF-8 one included, though the
catalog reports code page 65001 for it; bytes of UTF-8 for char/varchar
under a UTF-8 collation; bytes of its code page under any other. binary(n)
pads and says so. IDENTITY is identity-always; a default that is exactly
NEXT VALUE FOR a sequence numbers a row an insert leaves out and takes a
value given by hand, as PostgreSQL's BY DEFAULT identity does, and is
identity-by-default, keeping its default. Without VIEW DEFINITION that
definition is NULL, the column reads as an ordinary default, and the
snapshot's defaults gap says so. A check is enforced unless it is
disabled, and validated unless it is untrusted — which every disabled check
also is.
Every function here takes a connected pool rather than a connection string:
opening the connection is where a secret is handled, and that happens once, in
the composition root. Nothing in this package reads configuration.
adapter.discover(scope) is the same discovery through the DatabaseAdapter
port.
@types/mssql is a dependency rather than a devDependency because the pool is
in every function's signature. A consumer type-checking against the published
declarations needs it, and skipLibCheck would not save them, since the import
is in this package's own declaration file. The install gate checks exactly this.
Lookups
createSqlServerLookups(pool) answers formancy's option sources for the
foreign keys a form offers as lookups — a page of a search, labels for tokens a
form holds, and which submitted tokens are not members — under the actor's
trusted row filters, in the same statement
(0012,
0017):
import { buildLookupConfig, findObject, scopeRowFilters } from '@formancy/data-core'
import { createSqlServerLookups } from '@formancy/data-sqlserver'
const lookups = createSqlServerLookups(pool)
const config = buildLookupConfig(bindings, 'customer', { snapshot })
const customers = findObject(snapshot, config.target)
if (customers === undefined) throw new Error('the lookup target is not in the snapshot')
const scoped = scopeRowFilters(customers, [{ column: 'tenant_id', value: '1' }], 'lookups.customer')
if (!scoped.ok) throw new Error(scoped.message) // invalid-policy or invalid-context
const tenant = scoped.filters // each term carries its column's type
await lookups.search(config, { search: 'muster', offset: 0, limit: 50 }, tenant)
// { rows: [{ token: 'k1:1,1001', label: 'Muster AG' }], hasMore: false, omitted: 0 }
await lookups.rejects(config, ['k1:1,1001', 'k1:2,1001'], tenant) // ['k1:2,1001']: another tenant'sA search is a literal: %, _ and [ match themselves. How it matches case
and accents, and how text is ordered, is the column's collation — on a
case-insensitive database muster finds Muster AG and apple sorts before
Banana — which is SQL Server's rule and not code-point order. A
uniqueidentifier is ordered by no collation and not by its text: SQL Server
stores the text's first three groups little-endian and compares the bytes it
stores group by group from the last, so through the text it compares the last
six bytes first, then the two before them, then the first eight from the
right, where PostgreSQL compares a uuid's bytes in turn. So
options tied on their label, and a list ordered by a uuid key, come in
another order on each engine, total and stable on both
(0056). NULLs sort
where the configuration says, though SQL Server has no NULLS LAST. A row
whose key holds a NULL is never offered. Membership is the re-encoded row's,
because SQL Server's = finds CH for ch and A for A . Tokens are
asked about in groups of 2098 parameters, which is what one statement can
bind. A database error is thrown, which formancy turns into a refused
submission.
A row filter compares exactly
(0028):
the column's canonical value is the trusted value, so case, accents and
trailing spaces all count, as on PostgreSQL. SQL Server has no such operator —
every collation, BIN2 included, ignores trailing spaces — so a text filter is
three conjuncts on one parameter: = in the column's own collation, =
under Latin1_General_100_BIN2, and, for variable-length text, equal
datalengths. A non-text filter is bound as its column's type. The first
conjunct is the one an index seeks on: measured over 20,004 rows, an
nvarchar or nchar column seeks, and so does a char or varchar under a
Windows collation; a char or varchar under a SQL_ collation still
scans, as it did when the filter was BIN2 alone, which never seeked. The
parity suite reads the plan to hold the nvarchar case.
A label is the record reader's value, decoded as a record read decodes
it and spelled by displayText from @formancy/data-core: true for a bit,
a lower-case uuid, 0.1 for a float or a real, an instant in UTC to the
second, a time to the minute, whatever the session's language. A char(n)
reads without its padding, in a label, a key and a record alike, so a
fixed-length key has the token PostgreSQL gives it. An integer search column
is matched as its canonical text; text as itself, so its collation folds case
and accents.
What a lookup reads
0034
holds this without a clock, on the sized sales.customer of
@formancy/data-fixtures: lookups-sized.integration.test.ts runs each
statement through createSqlServerLookups as the writer and reads the actual
plan of exactly that statement, ActualRowsRead summed over every operator
and thread on [customer]. The counts it pins are sizedRowsRead() of
@formancy/data-fixtures, which the PostgreSQL suite and the performance
page read too. What it pins:
- Rows sent. A search asks for the page and one more row,
offset … fetchin SQL: the server never receives more. - Rows read. With the order form's tenant row filter, a search is a
Clustered Index Seek on the tenant and reads exactly that tenant's rows --
every one of them, for every search and for the first page, because a
leading-wildcard
likeand the order by name have no index to use. On a form with no tenant row filter it is a Clustered Index Scan of the whole table: the row-security predicate is not a seek. A resolve and the membership check read at most one row per key. - What the plans were (2026-10-09, SQL Server 2022 CU27 Developer, SQL_Latin1_General_CP1_CI_AS, a Docker Sandbox VM on a Windows 11 workstation): the tenant's seek runs in parallel; the generated names sort as the generator says they do (P1).
- How the rows read are seen (P2):
sys.dm_exec_query_plan_statsreportsActualRowsper thread but noActualRowsReadon this build, so the test reads the actual plan from an Extended Events session onquery_post_execution_showplan, limited to the writer's statements. NotSET STATISTICS XMLover text the test builds: a limit raised inlookups.tswould never show there. - What an index would not change (P5, an index on
(tenant_id, name, customer_no)created and dropped by the probe, never shipped): theCASE … IS NULLbefore each sort column keeps it from serving the order, so the first page still reads and sorts the whole tenant.
Every statement is two round trips: mssql's pool checks each connection with
SELECT 1; before handing it out (validateConnection, on by default and
not set by connectSqlServer), then sends the statement. The measurement's
counting pass counts it through a TCP hop on every run, and
docs/performance.md has the figures.
When a lookup's caller goes
Every lookup method takes the caller's AbortSignal, last
(0061).
The server aborts it when the browser abandons the request or the lookup
deadline passes, and the adapter stops the statement in the database:
await lookups.search(config, { search: 'muster', offset: 0, limit: 50 }, tenant, signal)- Already aborted: it rejects with
signal.reasonand sends nothing, not even the pool'sSELECT 1;. A resolve checks again before each group of keys, so an aborted one sends no further group. - Aborted while the statement waits or runs:
request.cancel()sends a TDS attention on the statement's own connection, and the method rejects with exactlysignal.reasonat once, without waiting for the server's acknowledgement. An abort before mssql holds a connection sends no statement at all. An answer read before the abort is returned unchanged. - Nobody else's statement is stopped. Attention is in-band: tedious waits for the server to acknowledge it before the connection takes another request, so the lookups share the records' pool. PostgreSQL's cancel request travels on a connection of its own and can land late, which is why that adapter needs a client of its own and SQL Server does not.
- No
requestTimeoutis set for a lookup. The deadline is the server's signal, one timer on both engines that also counts the wait for a pooled connection, which tedious's timer, started when the request is sent, does not. mssql's default request timeout of 15 seconds, which it hands tedious for every request (lib/tedious/connection-pool.jsin mssql 12.7.4), still bounds every statement, so on SQL Server aFORMANCY_DATA_LOOKUP_DEADLINE_MSabove 15000 is cut at 15 seconds, and the server answers that asunavailable, notlookup-timeout.
lookups-limits.integration.test.ts holds each of these against the real
server, with sales.customer behind blockTable's exclusive lock: the
lookup is seen waiting in sys.dm_exec_requests, and after the abort seen
gone while the lock is still held; nothing crosses the TCP hop for a signal
already aborted; and the one pooled connection answers the next lookup in
the same session, with no transaction open and the same SET options.
How long a stop takes was measured once, on 2026-10-14, not held by a
test: 30 searches waiting on that lock, after one warm-up, on an i9-10900K
Windows 11 workstation under Docker 28.3.3, the 2022 image
sha256:5b0916c7…, mssql 12.7.4 and tedious 20.3.3, through the built
adapter on a pool of one connection with no hop. From the abort, the method
rejected in 0.12 / 0.17 / 0.26 ms (min / median / max); the waiter was gone
from sys.dm_exec_requests, polled query after query, in 27.2 / 40.7 /
52.6 ms; and a request queued on the one connection at the abort was
answered in 29.0 / 40.8 / 52.4 ms. The design's probe, the same day and
machine, stopped a WAITFOR in 1.8 / 2.3 / 3.5 ms over 30 trials and a
running scan in 88.9 ms, once: how long a stop takes depends on what the
statement is doing, and none of these is a bound.
Records
createSqlServerRecords(pool) reads, inserts, updates and deletes one
record, and lists a page of records -- one statement, an OUTER APPLY with
OFFSET and FETCH beside the table's description, a nullable column's NULLs
placed by a CASE
(0015,
0017,
0044).
A delete runs in the write batch: delete ... output deleted... into, then
the batch's checks, an INSTEAD OF DELETE guard, and a reach guard that
refuses (51707, schema-changed) a key or an edge constraint that acts on
the delete, enabled or not, and an account that no longer holds VIEW
DEFINITION on the database or is bound by a VIEW DEFINITION or CONTROL deny,
by discovery's own conditions; more than one row rolls it back
(0050):
import { createSqlServerRecords } from '@formancy/data-sqlserver'
const records = createSqlServerRecords(pool)
const read = await records.read({ target, key, columns, filters, through: [] })
// { ok: true, described: { kind, columns, keys…, definition }, record: { values: { id: '9007199254740993', … }, version: '00000000000007d1' } }
const saved = await records.update({ target, key, set, expectedVersion: read.record.version, filters, through: [], returning, definition: read.described.definition })
// or { ok: false, code: 'stale' | 'not-found' | 'schema-changed' | 'unique-violation' | …, message }Past its record, the reach guard reads every table the delete removes rows
of and accepts exactly the keys its policy names in cascades, by the
object each is on, its name, the object it refers to and its action, and
refuses a key that follows a cleared column ON UPDATE; then 51708
(permission-denied) refuses a delete whose account may not DELETE from a
table it reaches, whatever other grant it holds there, which SQL Server's
own cascade never asks. Either rolls back what the cascade did
(0051).
cascade.integration.test.ts holds every case CASCADE_PARITY names but
lost-answer, which records-lost-answer.integration.test.ts holds.
A record is located by its key, the filters and every through (0043). The read, the update and the query that tells
stalefromnot-foundname the table[r]--update [r] set … from <table> as [r]-- and each through is anexists (select 1 from <target> as [p] where [p].<key> = [r].<foreign key> and <filters on [p]>), every column qualified, so a key onto the same table cannot bind the record's column to the parent. A row another tenant's parent holds isnot-foundon read and on update, whatever version is sent. An update that waited for its row while another transaction moved the parent out of the scope isnot-foundtoo, with READ_COMMITTED_SNAPSHOT off and on, where PostgreSQL writes it (the shared casethrough: moved-parent). The EXISTS's plan is not measured.Every write holds the table to the definition it was decided over (0041).
describe(table)reads the root's definition in one statement, and a read returns it from the statement that read the record, which holds a schema-stability lock on the table while it runs. The facts are one FOR JSON text read by discovery's own queries for the object's kind, its column definitions, its keys and its foreign keys, without access or comment; the definition is their HASHBYTES SHA-256. The write batch checks it after its statement, while its locks keep an ALTER out, and again in CATCH after the rollback, so a statement that failed against a moved table -- 245 for'many'into a column retyped to int -- is answered as the move: 51706,schema-changed, and the batch rolled the write back, its triggers' work included. CATCH asks only while the transaction is still the batch's own: one a trigger ended isunknown-outcomewhatever the table did, because the trigger may have committed the write (0031). The facts depend on the account, which the catalog filters by permission: an account without VIEW DEFINITION reads a default's definition as NULL, and the definition suite holds that its definition moves exactly when the owner's does. Measured on 2026-10-10 outside the suites, on one shared machine: 1.4 ms of CPU for a describe, 1.7 to 2.2 ms for a read where the read without the description took 0.1 ms, and 2.1 to 2.6 ms for a write where its batch took 0.66 to 0.77 ms. Masking is not among the facts: a column masked after publication reads as its mask to an account without UNMASK, and nothing compares it. A text the client receives cut by the connection's TEXTSIZE isunavailable, never parsed; an account the catalog hides the table from cannot have it described, and isschema-changed.Values leave as text the server produced, exactly what
codecFor(column).parsereturns: decimals padded to their scale (money to four places), integers as decimal strings, arealas the shortest decimal naming its float (0.1, never0.10000000149011612), datesYYYY-MM-DD, timesHH:MM, instants in UTC to the second, a zoneless timestamp with its fraction and no trailing zeros (2026-10-08T12:34:56.5), as PostgreSQL spells it (0026) — adatetime, which keeps 1/300 s, to the millisecond, rounded as SQL Server spells it — UUIDs in lower case. A time's seconds and an instant's fraction are cut off, because formancy's shapes cannot hold them, as the PostgreSQL adapter now cuts them too.planUpdatein@formancy/data-core, given the record as read, removes an unedited echo of either from an update and refuses to plan one without that read, so a save planned by it does not write the shorter value (0040); a caller that builds an update request itself, as above, must set only the fields a person changed. Text and decimals are read by the column's own type, not the snapshot's, so a column widened since discovery reads what it holds.Values arrive as text the server converts, never through the driver's typed parameters, whose decimal goes through a JavaScript number.
A write is one batch: the guarded statement, a check that every text column stored exactly the text it was sent — SQL Server turns
ŁAintoLAin a single-byte varchar without an error, and a varchar created underANSI_PADDING OFFstoresacmeforacme, and the adapter refuses both asout-of-range— and the commit. Any error rolls it back, a trigger'sRAISERRORincluded, which on its own would not stop the commit. It works on a table with triggers:returningis the row as the statement wrote it, before an AFTER trigger, and the version is the row's after one, read back by its key, so a trigger that touches the row does not make the next save stale.A trigger that decides what is stored is refused. An INSTEAD OF trigger runs in place of the statement, and what SQL Server returns is the row as if it had not: a write to a table with an enabled one for that operation — a view made writable by one included — is rolled back as
refused, and so is a write by an account that cannot see the table's triggers. A trigger that ends the write's transaction itself, by COMMIT or ROLLBACK, isunknown-outcome, because the batch cannot tell which -- whether or not it then raises an error of its own: the batch's CATCH sees that its transaction is gone, so a COMMIT followed by RAISERROR is no longer reported as the refusal it raised over a stored row, and a ROLLBACK followed by one is over-reported as unknown (0031). An AFTER trigger that deletes the row it fired for still misleads; 0017 says how.An update is one statement naming the key, the filters and the expected version, incrementing a version column in the same SET. A record outside the filters is
not-found, like one that does not exist.A refusal is a code, by its error number, with a sentence of the adapter's own that never repeats a value. A constraint or column is named when the message is English, which it is unless
options.languagewas set. A pool that hands out no connection — closed, or none free withinacquireTimeoutMillis— isunavailable, because nothing was sent. A connection lost after a write was sent isunknown-outcome, and nothing is retried (0031); see An answer lost after a write below. The numbers without a code of their own (0028):| Error | Code | |---|---| | 544, 8102 (an identity), 271 (a computed column), 273, 272 (a rowversion), 13536, 13537 (a GENERATED ALWAYS column, such as a system-versioning period's) |
schema-changed, as PostgreSQL's 428C9 | | 1205, 1222, 1204, 701, 8645, 8651, 9002, 1105, 3960, 3906 (a read-only database, PostgreSQL's 25006), 976, 983 (an availability replica not accessible now) |unavailable: SQL Server documents each as passing | | 50000 and above — a trigger's THROW or RAISERROR — and any other number |refused: nothing was written, and the same request would be refused again; unless a trigger ended the write's transaction before it, which isunknown-outcome(0031) |Of the
unavailablenumbers 1205, a deadlock victim, and 3906, a database switched to read-only, are provoked by a test; the others are by documentation. Everyschema-changednumber is provoked, each by a column that became generated after discovery: 273 and 272 because the adapter binds no rowversion value itself, 13536 and 13537 through a column that was adatetimeoffsetand became a period'sdatetime2, because a zoneless timestamp is never written. A number of 50000 or above is said to be a trigger's or a procedure's own, and the suite reads that sentence.
What an account needs. SELECT on what a form reads and a lookup offers,
and INSERT and UPDATE on what a form writes — with SELECT on it too: a
write reads back through OUTPUT the columns it returns, the text it checks
and the key it finds the row by again, and SQL Server asks SELECT for
every column OUTPUT names, refusing an INSERT-only grant with 229. A grant
it lacks is permission-denied for a record and a thrown error for a
lookup. A row a security policy's block predicate refuses (33504) is
permission-denied too, as PostgreSQL's row-level security refusal is
there. An account denied VIEW DEFINITION on a table is refused every
request of a form over it, reads included: the catalog does not show it the
table, so the table cannot be described, and a write decided over another
account's description finds the table's facts NULL and is refused as a
moved definition, so whether a trigger decides it never has to be seen
(0041).
What the spike found about the driver
src/spike.integration.test.ts keeps the phase-1 spike as tests, so a driver
upgrade that changes any of this fails by name:
- By default
tediousreturnsbigintas an exact decimal string, anddecimal(18,4)as a JavaScript number: the largest one, 99999999999999.9999, arrives as 100000000000000. Converting to text in theSELECTreads both exactly. - A
bigintkey past 2^53 bound as a JavaScript number addresses a different row; bound as its decimal string it addresses the right one. - A
datearrives as a JavaScriptDateat midnight UTC. nvarchar(n)holds n UTF-16 code units, andvarchar(n)under a UTF-8 collation holds n bytes; neither is n characters. Discovery says which, as each text column'slengthUnit, from its own collation's code page, and the codec counts in it (0026).- Optimistic concurrency on
rowversionholds between two independent connections: the update that names the current 8-byte token wins, and the stale one changes no rows.
The operations suites added more of the same, each a test that fails by name
if it changes: a Decimal(18,4) parameter stores 1234567890123.4567 as
1234567890123.4568; a request binds 2098 parameters, not the 2100 the
message names; OUTPUT without INTO is refused on a table with a trigger
(334); nvarchar into a single-byte varchar takes a "best fit" or ? without an
error; a varchar created under ANSI_PADDING OFF drops a trailing space
without one; BIN2 equality ignores trailing spaces; a char(n) keeps its
padding through a conversion to nvarchar, and rtrim removes U+0020 alone;
a deadlock victim is 1205; and a German session gets error 547 with its FOREIGN KEY and CHECK
keywords untranslated. That all 34 languages on the 2022 image keep them was
checked once, by hand, on 2026-10-09, and is not a test.
Tests
pnpm test starts SQL Server through @formancy/data-fixtures, once per
test file, and runs the suite against it: discovery, the spike, lookups,
what a lookup reads on the sized customers (0034), a lookup stopped when
its caller goes (0061), records, a page of them and a file of the
shipments written by data-core (list.integration.test.ts, 0044, 0079),
the ways a record operation fails, a write whose answer is lost on the way back, and a
uuid key end to end (uuid.integration.test.ts, 0056: discovered, generated
read-only, its token in lower case, created, updated, listed in this
engine's order and offered by a lookup, tied options in that order too,
a delivery deleted by its uuid token with its parcels and then its order
with what it reaches once the policy names each key the delete acts
through (0051), each case UUID_PARITY's, and which default makes a
version column prove a record over such a key (0057): newsequentialid()
filling the whole key,
however the DDL writes it, and not a constant, newid(), a function of
another schema named newsequentialid, or newsequentialid() beside a
pinned tenant or on a key that refers to another table's, each as the
catalog spells it -- with a row of the first read and deleted by its token
and answered in lower case; and, this engine's own, how it stores and orders
a uniqueidentifier, and that newsequentialid()'s keys rise across
tenants). It
needs Docker and, the first time, a
pull of about a gigabyte and a half. encryption.test.ts needs none: the
real mssql and tedious against data-fixtures' PRELOGIN stand-in, which
answers in the server's place that it will not encrypt and keeps the type
of every packet the client sends after, and the guard's parser and stream
over bytes and a loopback socket (0081); that a real server's TLS passes
the guard is adapter.integration.test.ts's, on each image. There is no mocked driver to fall back
to — database semantics are what this package is for (0003). No test runs
on Azure SQL Database or Azure SQL Managed Instance: neither has an image a
test can start, and that EngineEdition 5 and 8 are read as them is a
unit test of the values Microsoft documents, not of a server's answer.
Every suite runs the Developer edition on Linux in a container, and
another edition or platform of a release the matrix runs, SQL Server on
Azure Virtual Machines among them, is called tested on its strength
(0073).
The suites start mcr.microsoft.com/mssql/server:2022-latest, or the image a file names for a fact of its own, through @azure/identity 4.13.3 (published as 4.13.3), @types/mssql 12.3.0 (published as ^12.3.0), mssql 12.7.4 (published as ^12.0.0) and the tedious 20.3.7 under it (^19.2.2 || ^20.0.0, by mssql); CI runs them again on mcr.microsoft.com/mssql/server:2019-latest and mcr.microsoft.com/mssql/server:2025-latest, each named in FORMANCY_IMAGES.
Licence
Source-available, not open source. See LICENSE.md.
