@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/README.md CHANGED
@@ -1,3 +1,551 @@
1
- # Temporary Holding Version
1
+ <!-- Part of Formancy Data: https://github.com/sharkysan/formancy.ai-data -->
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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).