@formancy/data-sqlserver 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +156 -0
- package/NOTICE +9 -0
- package/README.md +550 -2
- package/dist/index.d.mts +115 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2797 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -3
package/README.md
CHANGED
|
@@ -1,3 +1,551 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- Part of Formancy Data: https://github.com/sharkysan/formancy.ai-data -->
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# @formancy/data-sqlserver
|
|
4
|
+
|
|
5
|
+
The Microsoft SQL Server adapter for Formancy Data, behind the port
|
|
6
|
+
`@formancy/data-core` defines. Built on [`mssql`](https://github.com/tediousjs/node-mssql)
|
|
7
|
+
over the pure-JavaScript `tedious` driver, so there is no native module and no
|
|
8
|
+
ODBC to install.
|
|
9
|
+
|
|
10
|
+
## What is here today
|
|
11
|
+
|
|
12
|
+
An adapter that reaches a real server and says which version answered:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { connectSqlServer, createSqlServerAdapter } from '@formancy/data-sqlserver'
|
|
16
|
+
|
|
17
|
+
const pool = await connectSqlServer({ host, port, database, user, password, encrypt: true, trustServerCertificate: false })
|
|
18
|
+
const adapter = createSqlServerAdapter(pool)
|
|
19
|
+
await adapter.ping() // { kind: 'sqlserver', version: '16.0.4205.1' }
|
|
20
|
+
await adapter.close()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Open the pool with `connectSqlServer`, not with an `mssql` you imported
|
|
24
|
+
yourself. This package binds every value with `mssql.NVarChar` and friends from
|
|
25
|
+
its own copy of mssql, and tedious checks those by identity: a pool from
|
|
26
|
+
another copy refuses every parameter with "type.validate is not a function".
|
|
27
|
+
pnpm produced exactly that for the server, two installs of the same mssql
|
|
28
|
+
version, and only the end-to-end suite saw it
|
|
29
|
+
([0025](../../docs/decisions/0025-each-adapter-owns-its-driver.md)).
|
|
30
|
+
|
|
31
|
+
And discovery: what a connection can see of the tables and views in an
|
|
32
|
+
approved scope, as a `MetadataSnapshot` built by `@formancy/data-core`'s
|
|
33
|
+
`createSnapshot`, with what it could not see written down as gaps
|
|
34
|
+
([0007](../../docs/decisions/0007-sqlserver-discovery-and-what-it-hides.md)):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { discoverSqlServer } from '@formancy/data-sqlserver'
|
|
38
|
+
|
|
39
|
+
const snapshot = await discoverSqlServer(pool, { schemas: ['sales'] })
|
|
40
|
+
snapshot.objects // tables and views, columns with normalised types and access, keys, foreign keys, checks, row security
|
|
41
|
+
snapshot.account // { user: 'dbo', login: 'sa' }
|
|
42
|
+
snapshot.gaps // e.g. on sales.order: "fk_order_customer references a table this account cannot see: …"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
It reads SQL Server's catalog views, one concern per file under
|
|
46
|
+
`src/discovery/`: objects, columns and their types, what the account may do
|
|
47
|
+
with each column, keys, foreign keys, checks, security policies, the account,
|
|
48
|
+
and what the account cannot see. The scope's schema names are bound as
|
|
49
|
+
parameters and matched by the database's own collation. Discovery is not one
|
|
50
|
+
consistent read: a concurrent `ALTER` can tear it, and `createSnapshot` then
|
|
51
|
+
refuses what no catalog could have produced. A column and its privileges are
|
|
52
|
+
read in one statement, so no column lacks an answer; but the privilege check
|
|
53
|
+
looks a column up by name when it runs, so a concurrent rename can answer
|
|
54
|
+
"not granted" for a column that is, until the next snapshot.
|
|
55
|
+
|
|
56
|
+
**An answer lost after a write.** `records-lost-answer.integration.test.ts`
|
|
57
|
+
puts a TCP hop in front of the server that drops its answer, so every byte is
|
|
58
|
+
tedious's and the server's and only the network fails (0031). What it proves
|
|
59
|
+
on SQL Server 2022, with the driver versions *Tests* names:
|
|
60
|
+
|
|
61
|
+
- **An insert or an update whose answer is lost after it committed** is
|
|
62
|
+
`unknown-outcome`. The write crossed the hop once and is stored once; an
|
|
63
|
+
update's change is stored and its rowversion has moved past the one sent,
|
|
64
|
+
so saving again with that version is `stale`, not a second write.
|
|
65
|
+
- **A request timeout after the commit** is `unknown-outcome` too, and the
|
|
66
|
+
row is stored: tedious gives up at `requestTimeout`, and again at
|
|
67
|
+
`cancelTimeout` when the answer to its cancel is lost as well. The suite's
|
|
68
|
+
`requestTimeout` is five seconds, against a commit whose answer reached the
|
|
69
|
+
hop 40 ms after the send, because an attention that reached the server
|
|
70
|
+
before the commit would cancel the batch and prove nothing. On
|
|
71
|
+
PostgreSQL a server-side `statement_timeout` rolls back and is
|
|
72
|
+
`unavailable`; here the timeout is the client's, and says nothing about the
|
|
73
|
+
commit.
|
|
74
|
+
- **A socket cut while the write waits on a lock** is `unknown-outcome`,
|
|
75
|
+
which over-reports: SQL Server ends a session whose client went away, the
|
|
76
|
+
session is gone shortly after, and once the lock is released nothing was
|
|
77
|
+
stored. PostgreSQL does the opposite, and an orphaned write there commits
|
|
78
|
+
after the release.
|
|
79
|
+
- **What the driver rejects a lost answer with** is a `RequestError` whose
|
|
80
|
+
`number` is the string `'ECONNRESET'` for a cut socket and `'ETIMEOUT'` for
|
|
81
|
+
a timeout, with no `class`: mssql copies a driver error's `code` into
|
|
82
|
+
`number` when it has no `info`. That every failure after a connection is
|
|
83
|
+
handed out is a `RequestError` was read in mssql's source for
|
|
84
|
+
[0017](../../docs/decisions/0017-sqlserver-operations.md); it is now tested,
|
|
85
|
+
for a socket and a timeout. It is why `serverError` asks `typeof number ===
|
|
86
|
+
'number'`: a lost answer read as the server's refusal would be `refused`.
|
|
87
|
+
|
|
88
|
+
**What an account needs.** For a snapshot with no gap, `VIEW DEFINITION` on
|
|
89
|
+
the database, and nothing else: no `SELECT`, no data access
|
|
90
|
+
([0027](../../docs/decisions/0027-a-snapshot-says-what-its-account-may-do.md)).
|
|
91
|
+
`VIEW DEFINITION` on each schema in scope describes every table and view the
|
|
92
|
+
owner sees, but not every security policy: a policy lives in a schema of its
|
|
93
|
+
own and can filter a table in any other, so row security is then `unknown`
|
|
94
|
+
except where a visible policy applies, and one scope gap says why.
|
|
95
|
+
`VIEW SECURITY DEFINITION` does not help; it lists no policy. Nor does a
|
|
96
|
+
schema grant show a foreign key on a table of another schema that refers to
|
|
97
|
+
one in scope, so what refers to each table is then a `referenced-by` scope
|
|
98
|
+
gap too, by the same rule
|
|
99
|
+
([0049](../../docs/decisions/0049-a-snapshot-says-whether-its-account-may-delete-and-what-refers-to-each-table.md));
|
|
100
|
+
whether the account may `DELETE` from a table is `HAS_PERMS_BY_NAME`'s answer
|
|
101
|
+
on the object, read for every object it lists. A graph node table that an
|
|
102
|
+
edge constraint connects carries a `referenced-by` gap under any grant, the
|
|
103
|
+
owner's included: an edge constraint is not a foreign key and is not
|
|
104
|
+
listed, yet a delete of a node removes the edges a `CASCADE` one connects,
|
|
105
|
+
and a `NO ACTION` one refuses it. Without either
|
|
106
|
+
grant, SQL Server lists only the objects the account holds a permission on and
|
|
107
|
+
returns `NULL` for every default and check definition, and the snapshot says so
|
|
108
|
+
per object and per schema rather than looking complete. A table someone has
|
|
109
|
+
denied `VIEW DEFINITION` or `CONTROL` on, whether to the account or to a role
|
|
110
|
+
it is in, vanishes even under that grant; the account can count those denials,
|
|
111
|
+
though not place them, and the snapshot says that too. Any such deny, on an
|
|
112
|
+
object or on a schema, leaves row security `unknown` even under the database
|
|
113
|
+
grant, because the hidden thing may be a policy. Only a deny the server
|
|
114
|
+
applies counts: one made to `public` binds no sysadmin and no `dbo`, so the
|
|
115
|
+
owner's snapshot stays complete.
|
|
116
|
+
|
|
117
|
+
**What the snapshot says the account may do.** Every column carries `access`:
|
|
118
|
+
whether the account may `SELECT` it, name it in an `INSERT` and in an
|
|
119
|
+
`UPDATE`, as `HAS_PERMS_BY_NAME` answers for the connected user, through every
|
|
120
|
+
role it is in. SQL Server grants `INSERT` on whole objects only, so every column
|
|
121
|
+
of an object the account may insert into says yes. A column denied `SELECT`
|
|
122
|
+
says so, where before only the first read found out. `rowSecurity` is
|
|
123
|
+
`applies` where an enabled security policy targets the object — SQL Server
|
|
124
|
+
exempts nobody, `dbo` included — `none` where the database grant establishes
|
|
125
|
+
that none does, and `unknown` otherwise. The snapshot names its `account`:
|
|
126
|
+
`USER_NAME()`, which the fingerprint carries, and `ORIGINAL_LOGIN()`, which it
|
|
127
|
+
does not; `sa` and any other sysadmin are `dbo`. Each is what was true at
|
|
128
|
+
discovery: a grant revoked afterwards still fails at runtime as
|
|
129
|
+
`permission-denied`.
|
|
130
|
+
|
|
131
|
+
**What a column is read as**
|
|
132
|
+
([0026](../../docs/decisions/0026-name-every-column-fact-the-engines-disagree-on.md)).
|
|
133
|
+
A text length is in the unit the column counts: UTF-16 code units for
|
|
134
|
+
`nchar`/`nvarchar` under every collation, a UTF-8 one included, though the
|
|
135
|
+
catalog reports code page 65001 for it; bytes of UTF-8 for `char`/`varchar`
|
|
136
|
+
under a UTF-8 collation; bytes of its code page under any other. `binary(n)`
|
|
137
|
+
pads and says so. `IDENTITY` is `identity-always`; a default that is exactly
|
|
138
|
+
`NEXT VALUE FOR` a sequence numbers a row an insert leaves out and takes a
|
|
139
|
+
value given by hand, as PostgreSQL's BY DEFAULT identity does, and is
|
|
140
|
+
`identity-by-default`, keeping its default. Without `VIEW DEFINITION` that
|
|
141
|
+
definition is `NULL`, the column reads as an ordinary default, and the
|
|
142
|
+
snapshot's `defaults` gap says so. A check is `enforced` unless it is
|
|
143
|
+
disabled, and `validated` unless it is untrusted — which every disabled check
|
|
144
|
+
also is.
|
|
145
|
+
|
|
146
|
+
Every function here takes a connected pool rather than a connection string:
|
|
147
|
+
opening the connection is where a secret is handled, and that happens once, in
|
|
148
|
+
the composition root. Nothing in this package reads configuration.
|
|
149
|
+
`adapter.discover(scope)` is the same discovery through the `DatabaseAdapter`
|
|
150
|
+
port.
|
|
151
|
+
|
|
152
|
+
`@types/mssql` is a dependency rather than a devDependency because the pool is
|
|
153
|
+
in every function's signature. A consumer type-checking against the published
|
|
154
|
+
declarations needs it, and `skipLibCheck` would not save them, since the import
|
|
155
|
+
is in this package's own declaration file. The install gate checks exactly this.
|
|
156
|
+
|
|
157
|
+
### Lookups
|
|
158
|
+
|
|
159
|
+
`createSqlServerLookups(pool)` answers formancy's option sources for the
|
|
160
|
+
foreign keys a form offers as lookups — a page of a search, labels for tokens a
|
|
161
|
+
form holds, and which submitted tokens are not members — under the actor's
|
|
162
|
+
trusted row filters, in the same statement
|
|
163
|
+
([0012](../../docs/decisions/0012-a-lookup-token-is-a-reference-not-a-permission.md),
|
|
164
|
+
[0017](../../docs/decisions/0017-sqlserver-operations.md)):
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { buildLookupConfig, findObject, scopeRowFilters } from '@formancy/data-core'
|
|
168
|
+
import { createSqlServerLookups } from '@formancy/data-sqlserver'
|
|
169
|
+
|
|
170
|
+
const lookups = createSqlServerLookups(pool)
|
|
171
|
+
const config = buildLookupConfig(bindings, 'customer', { snapshot })
|
|
172
|
+
const customers = findObject(snapshot, config.target)
|
|
173
|
+
if (customers === undefined) throw new Error('the lookup target is not in the snapshot')
|
|
174
|
+
const scoped = scopeRowFilters(customers, [{ column: 'tenant_id', value: '1' }], 'lookups.customer')
|
|
175
|
+
if (!scoped.ok) throw new Error(scoped.message) // invalid-policy or invalid-context
|
|
176
|
+
const tenant = scoped.filters // each term carries its column's type
|
|
177
|
+
await lookups.search(config, { search: 'muster', offset: 0, limit: 50 }, tenant)
|
|
178
|
+
// { rows: [{ token: 'k1:1,1001', label: 'Muster AG' }], hasMore: false, omitted: 0 }
|
|
179
|
+
await lookups.rejects(config, ['k1:1,1001', 'k1:2,1001'], tenant) // ['k1:2,1001']: another tenant's
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A search is a literal: `%`, `_` and `[` match themselves. How it matches case
|
|
183
|
+
and accents, and how text is ordered, is the column's collation — on a
|
|
184
|
+
case-insensitive database `muster` finds `Muster AG` and `apple` sorts before
|
|
185
|
+
`Banana` — which is SQL Server's rule and not code-point order. A
|
|
186
|
+
`uniqueidentifier` is ordered by no collation and not by its text: SQL Server
|
|
187
|
+
stores the text's first three groups little-endian and compares the bytes it
|
|
188
|
+
stores group by group from the last, so through the text it compares the last
|
|
189
|
+
six bytes first, then the two before them, then the first eight from the
|
|
190
|
+
right, where PostgreSQL compares a `uuid`'s bytes in turn. So
|
|
191
|
+
options tied on their label, and a list ordered by a uuid key, come in
|
|
192
|
+
another order on each engine, total and stable on both
|
|
193
|
+
([0056](../../docs/decisions/0056-a-key-the-database-fills-is-never-written-and-a-uuid-is-ordered-as-each-engine-orders-it.md)). NULLs sort
|
|
194
|
+
where the configuration says, though SQL Server has no `NULLS LAST`. A row
|
|
195
|
+
whose key holds a NULL is never offered. Membership is the re-encoded row's,
|
|
196
|
+
because SQL Server's `=` finds `CH` for `ch` and `A` for `A `. Tokens are
|
|
197
|
+
asked about in groups of 2098 parameters, which is what one statement can
|
|
198
|
+
bind. A database error is thrown, which formancy turns into a refused
|
|
199
|
+
submission.
|
|
200
|
+
|
|
201
|
+
**A row filter compares exactly**
|
|
202
|
+
([0028](../../docs/decisions/0028-filters-labels-and-refusals-mean-the-same-on-both-engines.md)):
|
|
203
|
+
the column's canonical value is the trusted value, so case, accents and
|
|
204
|
+
trailing spaces all count, as on PostgreSQL. SQL Server has no such operator —
|
|
205
|
+
every collation, BIN2 included, ignores trailing spaces — so a text filter is
|
|
206
|
+
three conjuncts on one parameter: `=` in the column's own collation, `=`
|
|
207
|
+
under `Latin1_General_100_BIN2`, and, for variable-length text, equal
|
|
208
|
+
`datalength`s. A non-text filter is bound as its column's type. The first
|
|
209
|
+
conjunct is the one an index seeks on: measured over 20,004 rows, an
|
|
210
|
+
`nvarchar` or `nchar` column seeks, and so does a `char` or `varchar` under a
|
|
211
|
+
Windows collation; a `char` or `varchar` under a `SQL_` collation still
|
|
212
|
+
scans, as it did when the filter was BIN2 alone, which never seeked. The
|
|
213
|
+
parity suite reads the plan to hold the `nvarchar` case.
|
|
214
|
+
|
|
215
|
+
**A label is the record reader's value**, decoded as a record read decodes
|
|
216
|
+
it and spelled by `displayText` from `@formancy/data-core`: `true` for a bit,
|
|
217
|
+
a lower-case uuid, `0.1` for a float or a real, an instant in UTC to the
|
|
218
|
+
second, a time to the minute, whatever the session's language. A `char(n)`
|
|
219
|
+
reads without its padding, in a label, a key and a record alike, so a
|
|
220
|
+
fixed-length key has the token PostgreSQL gives it. An integer search column
|
|
221
|
+
is matched as its canonical text; text as itself, so its collation folds case
|
|
222
|
+
and accents.
|
|
223
|
+
|
|
224
|
+
### What a lookup reads
|
|
225
|
+
|
|
226
|
+
[0034](../../docs/decisions/0034-performance-is-measured-through-the-shipped-server-and-held-without-a-clock.md)
|
|
227
|
+
holds this without a clock, on the sized `sales.customer` of
|
|
228
|
+
`@formancy/data-fixtures`: `lookups-sized.integration.test.ts` runs each
|
|
229
|
+
statement through `createSqlServerLookups` as the writer and reads the actual
|
|
230
|
+
plan of exactly that statement, `ActualRowsRead` summed over every operator
|
|
231
|
+
and thread on `[customer]`. The counts it pins are `sizedRowsRead()` of
|
|
232
|
+
`@formancy/data-fixtures`, which the PostgreSQL suite and the performance
|
|
233
|
+
page read too. What it pins:
|
|
234
|
+
|
|
235
|
+
- **Rows sent.** A search asks for the page and one more row, `offset …
|
|
236
|
+
fetch` in SQL: the server never receives more.
|
|
237
|
+
- **Rows read.** With the order form's tenant row filter, a search is a
|
|
238
|
+
Clustered Index Seek on the tenant and reads exactly that tenant's rows --
|
|
239
|
+
every one of them, for every search and for the first page, because a
|
|
240
|
+
leading-wildcard `like` and the order by name have no index to use. On a
|
|
241
|
+
form with no tenant row filter it is a Clustered Index Scan of the whole
|
|
242
|
+
table: the row-security predicate is not a seek. A resolve and the
|
|
243
|
+
membership check read at most one row per key.
|
|
244
|
+
- **What the plans were** (2026-10-09, SQL Server 2022 CU27 Developer,
|
|
245
|
+
SQL_Latin1_General_CP1_CI_AS, a Docker Sandbox VM on a Windows 11
|
|
246
|
+
workstation): the tenant's seek runs in parallel; the generated names
|
|
247
|
+
sort as the generator says they do (P1).
|
|
248
|
+
- **How the rows read are seen** (P2): `sys.dm_exec_query_plan_stats`
|
|
249
|
+
reports `ActualRows` per thread but no `ActualRowsRead` on this build,
|
|
250
|
+
so the test reads the actual plan from an Extended Events session on
|
|
251
|
+
`query_post_execution_showplan`, limited to the writer's statements. Not
|
|
252
|
+
`SET STATISTICS XML` over text the test builds: a limit raised in
|
|
253
|
+
`lookups.ts` would never show there.
|
|
254
|
+
- **What an index would not change** (P5, an index on `(tenant_id, name,
|
|
255
|
+
customer_no)` created and dropped by the probe, never shipped): the
|
|
256
|
+
`CASE … IS NULL` before each sort column keeps it from serving the order,
|
|
257
|
+
so the first page still reads and sorts the whole tenant.
|
|
258
|
+
|
|
259
|
+
Every statement is two round trips: mssql's pool checks each connection with
|
|
260
|
+
`SELECT 1;` before handing it out (`validateConnection`, on by default and
|
|
261
|
+
not set by `connectSqlServer`), then sends the statement. The measurement's
|
|
262
|
+
counting pass counts it through a TCP hop on every run, and
|
|
263
|
+
[`docs/performance.md`](../../docs/performance.md) has the figures.
|
|
264
|
+
|
|
265
|
+
### When a lookup's caller goes
|
|
266
|
+
|
|
267
|
+
Every lookup method takes the caller's `AbortSignal`, last
|
|
268
|
+
([0061](../../docs/decisions/0061-a-lookup-statement-stops-when-its-request-is-abandoned-or-its-deadline-passes.md)).
|
|
269
|
+
The server aborts it when the browser abandons the request or the lookup
|
|
270
|
+
deadline passes, and the adapter stops the statement in the database:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
await lookups.search(config, { search: 'muster', offset: 0, limit: 50 }, tenant, signal)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
- **Already aborted:** it rejects with `signal.reason` and sends nothing, not
|
|
277
|
+
even the pool's `SELECT 1;`. A resolve checks again before each group of
|
|
278
|
+
keys, so an aborted one sends no further group.
|
|
279
|
+
- **Aborted while the statement waits or runs:** `request.cancel()` sends a
|
|
280
|
+
TDS attention on the statement's own connection, and the method rejects
|
|
281
|
+
with exactly `signal.reason` at once, without waiting for the server's
|
|
282
|
+
acknowledgement. An abort before mssql holds a connection sends no
|
|
283
|
+
statement at all. An answer read before the abort is returned unchanged.
|
|
284
|
+
- **Nobody else's statement is stopped.** Attention is in-band: tedious waits
|
|
285
|
+
for the server to acknowledge it before the connection takes another
|
|
286
|
+
request, so the lookups share the records' pool. PostgreSQL's cancel
|
|
287
|
+
request travels on a connection of its own and can land late, which is
|
|
288
|
+
why that adapter needs a client of its own and SQL Server does not.
|
|
289
|
+
- **No `requestTimeout` is set for a lookup.** The deadline is the server's
|
|
290
|
+
signal, one timer on both engines that also counts the wait for a pooled
|
|
291
|
+
connection, which tedious's timer, started when the request is sent, does
|
|
292
|
+
not. mssql's default request timeout of 15 seconds, which it hands tedious
|
|
293
|
+
for every request (`lib/tedious/connection-pool.js` in mssql 12.7.4), still
|
|
294
|
+
bounds every statement, so on SQL Server a
|
|
295
|
+
`FORMANCY_DATA_LOOKUP_DEADLINE_MS` above 15000 is cut at 15 seconds, and
|
|
296
|
+
the server answers that as `unavailable`, not `lookup-timeout`.
|
|
297
|
+
|
|
298
|
+
`lookups-limits.integration.test.ts` holds each of these against the real
|
|
299
|
+
server, with `sales.customer` behind `blockTable`'s exclusive lock: the
|
|
300
|
+
lookup is seen waiting in `sys.dm_exec_requests`, and after the abort seen
|
|
301
|
+
gone while the lock is still held; nothing crosses the TCP hop for a signal
|
|
302
|
+
already aborted; and the one pooled connection answers the next lookup in
|
|
303
|
+
the same session, with no transaction open and the same `SET` options.
|
|
304
|
+
How long a stop takes was measured once, on 2026-10-14, not held by a
|
|
305
|
+
test: 30 searches waiting on that lock, after one warm-up, on an i9-10900K
|
|
306
|
+
Windows 11 workstation under Docker 28.3.3, the 2022 image
|
|
307
|
+
`sha256:5b0916c7…`, mssql 12.7.4 and tedious 20.3.3, through the built
|
|
308
|
+
adapter on a pool of one connection with no hop. From the abort, the method
|
|
309
|
+
rejected in 0.12 / 0.17 / 0.26 ms (min / median / max); the waiter was gone
|
|
310
|
+
from `sys.dm_exec_requests`, polled query after query, in 27.2 / 40.7 /
|
|
311
|
+
52.6 ms; and a request queued on the one connection at the abort was
|
|
312
|
+
answered in 29.0 / 40.8 / 52.4 ms. The design's probe, the same day and
|
|
313
|
+
machine, stopped a `WAITFOR` in 1.8 / 2.3 / 3.5 ms over 30 trials and a
|
|
314
|
+
running scan in 88.9 ms, once: how long a stop takes depends on what the
|
|
315
|
+
statement is doing, and none of these is a bound.
|
|
316
|
+
|
|
317
|
+
### Records
|
|
318
|
+
|
|
319
|
+
`createSqlServerRecords(pool)` reads, inserts, updates and deletes one
|
|
320
|
+
record, and lists a page of records -- one statement, an OUTER APPLY with
|
|
321
|
+
OFFSET and FETCH beside the table's description, a nullable column's NULLs
|
|
322
|
+
placed by a CASE
|
|
323
|
+
([0015](../../docs/decisions/0015-a-record-operation-is-one-guarded-statement.md),
|
|
324
|
+
[0017](../../docs/decisions/0017-sqlserver-operations.md),
|
|
325
|
+
[0044](../../docs/decisions/0044-a-forms-records-are-listed-a-page-at-a-time-under-its-read-policy.md)).
|
|
326
|
+
A delete runs in the write batch: `delete ... output deleted... into`, then
|
|
327
|
+
the batch's checks, an INSTEAD OF DELETE guard, and a reach guard that
|
|
328
|
+
refuses (51707, `schema-changed`) a key or an edge constraint that acts on
|
|
329
|
+
the delete, enabled or not, and an account that no longer holds VIEW
|
|
330
|
+
DEFINITION on the database or is bound by a VIEW DEFINITION or CONTROL deny,
|
|
331
|
+
by discovery's own conditions; more than one row rolls it back
|
|
332
|
+
([0050](../../docs/decisions/0050-a-record-is-deleted-one-at-a-time-by-its-token-and-the-version-its-person-read.md)):
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { createSqlServerRecords } from '@formancy/data-sqlserver'
|
|
336
|
+
|
|
337
|
+
const records = createSqlServerRecords(pool)
|
|
338
|
+
const read = await records.read({ target, key, columns, filters, through: [] })
|
|
339
|
+
// { ok: true, described: { kind, columns, keys…, definition }, record: { values: { id: '9007199254740993', … }, version: '00000000000007d1' } }
|
|
340
|
+
const saved = await records.update({ target, key, set, expectedVersion: read.record.version, filters, through: [], returning, definition: read.described.definition })
|
|
341
|
+
// or { ok: false, code: 'stale' | 'not-found' | 'schema-changed' | 'unique-violation' | …, message }
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Past its record, the reach guard reads every table the delete removes rows
|
|
345
|
+
of and accepts exactly the keys its policy names in `cascades`, by the
|
|
346
|
+
object each is on, its name, the object it refers to and its action, and
|
|
347
|
+
refuses a key that follows a cleared column ON UPDATE; then 51708
|
|
348
|
+
(`permission-denied`) refuses a delete whose account may not DELETE from a
|
|
349
|
+
table it reaches, whatever other grant it holds there, which SQL Server's
|
|
350
|
+
own cascade never asks. Either rolls back what the cascade did
|
|
351
|
+
([0051](../../docs/decisions/0051-a-delete-reaches-other-tables-only-through-foreign-keys-the-policy-names.md)).
|
|
352
|
+
`cascade.integration.test.ts` holds every case `CASCADE_PARITY` names but
|
|
353
|
+
`lost-answer`, which `records-lost-answer.integration.test.ts` holds.
|
|
354
|
+
|
|
355
|
+
- **A record is located by its key, the filters and every through**
|
|
356
|
+
([0043](../../docs/decisions/0043-a-child-forms-rows-are-reached-only-through-a-parent-its-policy-admits.md)).
|
|
357
|
+
The read, the update and the query that tells `stale` from `not-found`
|
|
358
|
+
name the table `[r]` -- `update [r] set … from <table> as [r]` -- and each
|
|
359
|
+
through is an `exists (select 1 from <target> as [p] where [p].<key> =
|
|
360
|
+
[r].<foreign key> and <filters on [p]>)`, every column qualified, so a key
|
|
361
|
+
onto the same table cannot bind the record's column to the parent. A row
|
|
362
|
+
another tenant's parent holds is `not-found` on read and on update,
|
|
363
|
+
whatever version is sent. An update that waited for its row while another
|
|
364
|
+
transaction moved the parent out of the scope is `not-found` too, with
|
|
365
|
+
READ_COMMITTED_SNAPSHOT off and on, where PostgreSQL writes it (the
|
|
366
|
+
shared case `through: moved-parent`). The EXISTS's plan is not measured.
|
|
367
|
+
- **Every write holds the table to the definition it was decided over**
|
|
368
|
+
([0041](../../docs/decisions/0041-the-runtime-refuses-what-drift-blocks.md)).
|
|
369
|
+
`describe(table)` reads the root's definition in one statement, and a read
|
|
370
|
+
returns it from the statement that read the record, which holds a
|
|
371
|
+
schema-stability lock on the table while it runs. The facts are one FOR
|
|
372
|
+
JSON text read by discovery's own queries for the object's kind, its
|
|
373
|
+
column definitions, its keys and its foreign keys, without access or
|
|
374
|
+
comment; the definition is their HASHBYTES SHA-256. The write batch checks
|
|
375
|
+
it after its statement, while its locks keep an ALTER out, and again in
|
|
376
|
+
CATCH after the rollback, so a statement that failed against a moved table
|
|
377
|
+
-- 245 for `'many'` into a column retyped to int -- is answered as the
|
|
378
|
+
move: 51706, `schema-changed`, and the batch rolled the write back, its
|
|
379
|
+
triggers' work included. CATCH asks only while the transaction is still
|
|
380
|
+
the batch's own: one a trigger ended is `unknown-outcome` whatever the
|
|
381
|
+
table did, because the trigger may have committed the write (0031). The
|
|
382
|
+
facts depend on the account, which the catalog filters by permission: an
|
|
383
|
+
account without VIEW DEFINITION reads a default's definition as NULL, and
|
|
384
|
+
the definition suite holds that its definition moves exactly when the
|
|
385
|
+
owner's does. Measured on 2026-10-10 outside the suites, on one shared
|
|
386
|
+
machine: 1.4 ms of CPU for a describe, 1.7 to 2.2 ms for a read where the
|
|
387
|
+
read without the description took 0.1 ms, and 2.1 to 2.6 ms for a write
|
|
388
|
+
where its batch took 0.66 to 0.77 ms. Masking is not among the facts:
|
|
389
|
+
a column masked after publication reads as its mask to an account
|
|
390
|
+
without UNMASK, and nothing compares it. A text the client receives cut by the
|
|
391
|
+
connection's TEXTSIZE is `unavailable`, never parsed; an account the
|
|
392
|
+
catalog hides the table from cannot have it described, and is
|
|
393
|
+
`schema-changed`.
|
|
394
|
+
|
|
395
|
+
- **Values leave as text the server produced**, exactly what
|
|
396
|
+
`codecFor(column).parse` returns: decimals padded to their scale (money to
|
|
397
|
+
four places), integers as decimal strings, a `real` as the shortest decimal
|
|
398
|
+
naming its float (`0.1`, never `0.10000000149011612`), dates `YYYY-MM-DD`,
|
|
399
|
+
times `HH:MM`, instants in UTC to the second, a zoneless timestamp with its
|
|
400
|
+
fraction and no trailing zeros (`2026-10-08T12:34:56.5`), as PostgreSQL
|
|
401
|
+
spells it ([0026](../../docs/decisions/0026-name-every-column-fact-the-engines-disagree-on.md)) —
|
|
402
|
+
a `datetime`, which keeps 1/300 s, to the millisecond, rounded as SQL
|
|
403
|
+
Server spells it —
|
|
404
|
+
UUIDs in lower case. A time's seconds and an instant's fraction are cut
|
|
405
|
+
off, because formancy's shapes cannot hold them, as the PostgreSQL adapter
|
|
406
|
+
now cuts them too. `planUpdate` in `@formancy/data-core`, given the record
|
|
407
|
+
as read, removes an unedited echo of either from an update and refuses to
|
|
408
|
+
plan one without that read, so a save planned by it does not write the
|
|
409
|
+
shorter value
|
|
410
|
+
([0040](../../docs/decisions/0040-instants-and-times-are-read-to-the-shape-and-an-unedited-one-is-never-written.md)); a caller that builds an
|
|
411
|
+
update request itself, as above, must set only the fields a person
|
|
412
|
+
changed.
|
|
413
|
+
Text and
|
|
414
|
+
decimals are read by the column's own type, not the snapshot's, so a
|
|
415
|
+
column widened since discovery reads what it holds.
|
|
416
|
+
- **Values arrive as text the server converts**, never through the driver's
|
|
417
|
+
typed parameters, whose decimal goes through a JavaScript number.
|
|
418
|
+
- **A write is one batch**: the guarded statement, a check that every text
|
|
419
|
+
column stored exactly the text it was sent — SQL Server turns `ŁA` into
|
|
420
|
+
`LA` in a single-byte varchar without an error, and a varchar created under
|
|
421
|
+
`ANSI_PADDING OFF` stores `acme` for `acme `, and the adapter refuses both
|
|
422
|
+
as `out-of-range` — and the commit. Any error rolls it back, a trigger's
|
|
423
|
+
`RAISERROR` included, which on its own would not stop the commit. It works
|
|
424
|
+
on a table with triggers: `returning` is the row as the statement wrote it,
|
|
425
|
+
before an AFTER trigger, and the version is the row's after one, read back
|
|
426
|
+
by its key, so a trigger that touches the row does not make the next save
|
|
427
|
+
stale.
|
|
428
|
+
- **A trigger that decides what is stored is refused.** An INSTEAD OF
|
|
429
|
+
trigger runs in place of the statement, and what SQL Server returns is the
|
|
430
|
+
row as if it had not: a write to a table with an enabled one for that
|
|
431
|
+
operation — a view made writable by one included — is rolled back as
|
|
432
|
+
`refused`, and so is a write by an account that cannot see the table's
|
|
433
|
+
triggers. A trigger that ends the write's transaction itself, by COMMIT or
|
|
434
|
+
ROLLBACK, is `unknown-outcome`, because the batch cannot tell which --
|
|
435
|
+
whether or not it then raises an error of its own: the batch's CATCH sees
|
|
436
|
+
that its transaction is gone, so a COMMIT followed by RAISERROR is no
|
|
437
|
+
longer reported as the refusal it raised over a stored row, and a ROLLBACK
|
|
438
|
+
followed by one is over-reported as unknown
|
|
439
|
+
([0031](../../docs/decisions/0031-an-answer-lost-after-a-write-is-unknown.md)).
|
|
440
|
+
An AFTER trigger that deletes the row it fired for still misleads;
|
|
441
|
+
[0017](../../docs/decisions/0017-sqlserver-operations.md) says how.
|
|
442
|
+
- **An update is one statement** naming the key, the filters and the
|
|
443
|
+
expected version, incrementing a version column in the same SET. A record
|
|
444
|
+
outside the filters is `not-found`, like one that does not exist.
|
|
445
|
+
- **A refusal is a code**, by its error number, with a sentence of the
|
|
446
|
+
adapter's own that never repeats a value. A constraint or column is named
|
|
447
|
+
when the message is English, which it is unless `options.language` was set.
|
|
448
|
+
A pool that hands out no connection — closed, or none free within
|
|
449
|
+
`acquireTimeoutMillis` — is `unavailable`, because nothing was sent.
|
|
450
|
+
A connection lost after a write was sent is `unknown-outcome`, and nothing
|
|
451
|
+
is retried ([0031](../../docs/decisions/0031-an-answer-lost-after-a-write-is-unknown.md));
|
|
452
|
+
see *An answer lost after a write* below. The numbers without a code of
|
|
453
|
+
their own (0028):
|
|
454
|
+
|
|
455
|
+
| Error | Code |
|
|
456
|
+
|---|---|
|
|
457
|
+
| 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 |
|
|
458
|
+
| 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 |
|
|
459
|
+
| 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 is `unknown-outcome` (0031) |
|
|
460
|
+
|
|
461
|
+
Of the `unavailable` numbers 1205, a deadlock victim, and 3906, a database
|
|
462
|
+
switched to read-only, are provoked by a test; the others are by
|
|
463
|
+
documentation. Every `schema-changed` number is provoked, each by a column
|
|
464
|
+
that became generated after discovery: 273 and 272 because the adapter
|
|
465
|
+
binds no rowversion value itself, 13536 and 13537 through a column that was
|
|
466
|
+
a `datetimeoffset` and became a period's `datetime2`, because a zoneless
|
|
467
|
+
timestamp is never written. A number of 50000 or above is said to be a
|
|
468
|
+
trigger's or a procedure's own, and the suite reads that sentence.
|
|
469
|
+
|
|
470
|
+
**What an account needs.** `SELECT` on what a form reads and a lookup offers,
|
|
471
|
+
and `INSERT` and `UPDATE` on what a form writes — with `SELECT` on it too: a
|
|
472
|
+
write reads back through `OUTPUT` the columns it returns, the text it checks
|
|
473
|
+
and the key it finds the row by again, and SQL Server asks `SELECT` for
|
|
474
|
+
every column `OUTPUT` names, refusing an INSERT-only grant with 229. A grant
|
|
475
|
+
it lacks is `permission-denied` for a record and a thrown error for a
|
|
476
|
+
lookup. A row a security policy's block predicate refuses (33504) is
|
|
477
|
+
`permission-denied` too, as PostgreSQL's row-level security refusal is
|
|
478
|
+
there. An account denied `VIEW DEFINITION` on a table is refused every
|
|
479
|
+
request of a form over it, reads included: the catalog does not show it the
|
|
480
|
+
table, so the table cannot be described, and a write decided over another
|
|
481
|
+
account's description finds the table's facts NULL and is refused as a
|
|
482
|
+
moved definition, so whether a trigger decides it never has to be seen
|
|
483
|
+
([0041](../../docs/decisions/0041-the-runtime-refuses-what-drift-blocks.md)).
|
|
484
|
+
|
|
485
|
+
## What the spike found about the driver
|
|
486
|
+
|
|
487
|
+
`src/spike.integration.test.ts` keeps the phase-1 spike as tests, so a driver
|
|
488
|
+
upgrade that changes any of this fails by name:
|
|
489
|
+
|
|
490
|
+
- By default `tedious` returns `bigint` as an exact decimal string, and
|
|
491
|
+
`decimal(18,4)` as a JavaScript number: the largest one, 99999999999999.9999,
|
|
492
|
+
arrives as 100000000000000. Converting to text in the `SELECT` reads both
|
|
493
|
+
exactly.
|
|
494
|
+
- A `bigint` key past 2^53 bound as a JavaScript number addresses a different
|
|
495
|
+
row; bound as its decimal string it addresses the right one.
|
|
496
|
+
- A `date` arrives as a JavaScript `Date` at midnight UTC.
|
|
497
|
+
- `nvarchar(n)` holds n UTF-16 code units, and `varchar(n)` under a UTF-8
|
|
498
|
+
collation holds n bytes; neither is n characters. Discovery says which, as
|
|
499
|
+
each text column's `lengthUnit`, from its own collation's code page, and
|
|
500
|
+
the codec counts in it (0026).
|
|
501
|
+
- Optimistic concurrency on `rowversion` holds between two independent
|
|
502
|
+
connections: the update that names the current 8-byte token wins, and the
|
|
503
|
+
stale one changes no rows.
|
|
504
|
+
|
|
505
|
+
The operations suites added more of the same, each a test that fails by name
|
|
506
|
+
if it changes: a `Decimal(18,4)` parameter stores `1234567890123.4567` as
|
|
507
|
+
`1234567890123.4568`; a request binds 2098 parameters, not the 2100 the
|
|
508
|
+
message names; `OUTPUT` without `INTO` is refused on a table with a trigger
|
|
509
|
+
(334); nvarchar into a single-byte varchar takes a "best fit" or `?` without an
|
|
510
|
+
error; a varchar created under `ANSI_PADDING OFF` drops a trailing space
|
|
511
|
+
without one; BIN2 equality ignores trailing spaces; a char(n) keeps its
|
|
512
|
+
padding through a conversion to nvarchar, and `rtrim` removes U+0020 alone;
|
|
513
|
+
a deadlock victim is 1205; and a German session gets error 547 with its `FOREIGN KEY` and `CHECK`
|
|
514
|
+
keywords untranslated. That all 34 languages on the 2022 image keep them was
|
|
515
|
+
checked once, by hand, on 2026-10-09, and is not a test.
|
|
516
|
+
|
|
517
|
+
## Tests
|
|
518
|
+
|
|
519
|
+
`pnpm test` starts SQL Server through `@formancy/data-fixtures`, once per
|
|
520
|
+
test file, and runs the suite against it: discovery, the spike, lookups,
|
|
521
|
+
what a lookup reads on the sized customers (0034), a lookup stopped when
|
|
522
|
+
its caller goes (0061), records, the ways a
|
|
523
|
+
record operation fails, a write whose answer is lost on the way back, and a
|
|
524
|
+
uuid key end to end (`uuid.integration.test.ts`, 0056: discovered, generated
|
|
525
|
+
read-only, its token in lower case, created, updated, listed in this
|
|
526
|
+
engine's order and offered by a lookup, tied options in that order too,
|
|
527
|
+
a delivery deleted by its uuid token with its parcels and then its order
|
|
528
|
+
with what it reaches once the policy names each key the delete acts
|
|
529
|
+
through (0051), each case `UUID_PARITY`'s, and which default makes a
|
|
530
|
+
version column prove a record over such a key (0057): `newsequentialid()`
|
|
531
|
+
filling the whole key,
|
|
532
|
+
however the DDL writes it, and not a constant, `newid()`, a function of
|
|
533
|
+
another schema named `newsequentialid`, or `newsequentialid()` beside a
|
|
534
|
+
pinned tenant or on a key that refers to another table's, each as the
|
|
535
|
+
catalog spells it -- with a row of the first read and deleted by its token
|
|
536
|
+
and answered in lower case; and, this engine's own, how it stores and orders
|
|
537
|
+
a uniqueidentifier, and that `newsequentialid()`'s keys rise across
|
|
538
|
+
tenants). It
|
|
539
|
+
needs Docker and, the first time, a
|
|
540
|
+
pull of about a gigabyte and a half. There is no mocked driver to fall back
|
|
541
|
+
to — database semantics are what this package is for (0003).
|
|
542
|
+
|
|
543
|
+
<!-- generated by scripts/release-report/readme.mjs; do not edit -->
|
|
544
|
+
|
|
545
|
+
The suites start `mcr.microsoft.com/mssql/server:2022-latest` unless a test names another image, through `@types/mssql` 12.3.0 (published as `^12.3.0`), `mssql` 12.7.4 (published as `^12.0.0`) and the `tedious` 20.3.3 under it (`^19.2.2 || ^20.0.0`, by `mssql`).
|
|
546
|
+
|
|
547
|
+
<!-- end generated -->
|
|
548
|
+
|
|
549
|
+
## Licence
|
|
550
|
+
|
|
551
|
+
Source-available, not open source. See [`LICENSE.md`](./LICENSE.md).
|