@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/dist/index.mjs ADDED
@@ -0,0 +1,2797 @@
1
+ import { canonicalFloat32, createSnapshot, decodeRowversion, deleteTerms, encodeRowversion, inSnapshotOrder, listTerms, lookupKeys, lookupPage, rejectedTokens, resolvedRows, rowFilterTerms, throughTerms } from "@formancy/data-core";
2
+ import mssql from "mssql";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/discovery/account.ts
5
+ /**
6
+ * Whose snapshot this is (0027).
7
+ *
8
+ * USER_NAME() is the database principal every permission and every security
9
+ * predicate is evaluated for, and so the one the fingerprint carries.
10
+ * ORIGINAL_LOGIN() is who connected, for a person reading the report: a login
11
+ * mapped to a user of another name reports both (B13), and `sa` -- or any
12
+ * member of sysadmin, or the database's owner -- is `dbo` in the database.
13
+ * ORIGINAL_LOGIN rather than SUSER_SNAME, which an EXECUTE AS would change.
14
+ */
15
+ const SQL$3 = "select user_name() as [user], original_login() as login";
16
+ async function readAccount(pool) {
17
+ const row = (await pool.request().query(SQL$3)).recordset[0];
18
+ return {
19
+ user: row?.user ?? "",
20
+ login: row?.login ?? ""
21
+ };
22
+ }
23
+ //#endregion
24
+ //#region src/discovery/catalog.ts
25
+ /** The objects discovery describes, of sys.objects `o`: user tables and views. Shared with the root's definition (0041). */
26
+ const DESCRIBED_OBJECT = `o.type in ('U', 'V') and o.is_ms_shipped = 0`;
27
+ /**
28
+ * Runs one catalog query over the tables and views in scope.
29
+ *
30
+ * Each schema is bound as its own parameter, `@schema0`, `@schema1`, and the
31
+ * query text receives only those generated names: a schema name is a value
32
+ * and never part of the statement, whatever it contains. A list of
33
+ * parameters rather than one JSON or delimited parameter, because OPENJSON and
34
+ * STRING_SPLIT need compatibility level 130 and a customer's database is often
35
+ * older than its server.
36
+ *
37
+ * `scoped` is a subquery of the object_ids in scope: user tables and views
38
+ * whose schema the database's own collation matches to a scope name. Matching
39
+ * by the database's rules, not by exact spelling, because on a
40
+ * case-insensitive database `SALES` IS `sales`, and reporting it absent would
41
+ * be the silent answer.
42
+ */
43
+ async function queryScope(pool, schemas, sql) {
44
+ const request = pool.request();
45
+ const schemaList = schemas.map((schema, index) => {
46
+ request.input(`schema${String(index)}`, mssql.NVarChar(mssql.MAX), schema);
47
+ return `@schema${String(index)}`;
48
+ }).join(", ");
49
+ const scoped = `(select o.object_id from sys.objects o join sys.schemas s on s.schema_id = o.schema_id
50
+ where ${DESCRIBED_OBJECT} and s.name in (${schemaList}))`;
51
+ return (await request.query(sql(scoped, schemaList))).recordset;
52
+ }
53
+ /** Rows grouped by a key, each group in the order the query returned it. */
54
+ function groupBy(rows, key) {
55
+ const groups = /* @__PURE__ */ new Map();
56
+ for (const row of rows) {
57
+ const group = groups.get(key(row));
58
+ if (group === void 0) groups.set(key(row), [row]);
59
+ else group.push(row);
60
+ }
61
+ return groups;
62
+ }
63
+ const byObjectId = (row) => row.object_id;
64
+ //#endregion
65
+ //#region src/discovery/checks.ts
66
+ /**
67
+ * Check constraints, with the expression as SQL Server stored it -- which is
68
+ * not as it was written: `status in ('draft', 'placed', 'shipped')` comes back
69
+ * as `([status]='shipped' OR [status]='placed' OR [status]='draft')`.
70
+ *
71
+ * `enforced` is not `is_disabled`: ALTER TABLE … NOCHECK CONSTRAINT stops the
72
+ * check for new rows. `validated` is not `is_not_trusted`, which WITH NOCHECK
73
+ * sets and which disabling sets too, so a disabled check is never validated;
74
+ * a WITH NOCHECK one is enforced and not validated, and only `is_disabled`
75
+ * tells the two apart (0026). `is_not_for_replication` is not read: it exempts
76
+ * a replication agent's writes, which are not a form's.
77
+ */
78
+ const SQL$2 = (scoped) => `
79
+ select cc.parent_object_id as object_id, cc.name, cc.definition, cc.is_disabled, cc.is_not_trusted
80
+ from sys.check_constraints cc
81
+ where cc.parent_object_id in ${scoped}`;
82
+ async function readChecks(pool, schemas) {
83
+ const rows = await queryScope(pool, schemas, SQL$2);
84
+ const gaps = [];
85
+ const byObject = /* @__PURE__ */ new Map();
86
+ for (const [objectId, group] of groupBy(rows, byObjectId)) byObject.set(objectId, group.map((row) => {
87
+ if (row.definition === null) gaps.push({
88
+ objectId,
89
+ aspect: "checks",
90
+ detail: `${row.name}: the expression is hidden without VIEW DEFINITION`
91
+ });
92
+ return {
93
+ name: row.name,
94
+ expression: row.definition,
95
+ enforced: !row.is_disabled,
96
+ validated: !row.is_not_trusted
97
+ };
98
+ }));
99
+ return {
100
+ byObject,
101
+ gaps
102
+ };
103
+ }
104
+ //#endregion
105
+ //#region src/discovery/access.ts
106
+ /**
107
+ * The object a column belongs to, as HAS_PERMS_BY_NAME must be given it:
108
+ * QUOTENAME'd, schema and name each. The function PARSES the name, so a raw
109
+ * `it's [odd]` answers NULL (measured, B7), and a name that parses as a
110
+ * different object would answer for that one.
111
+ */
112
+ const OBJECT = `quotename(object_schema_name(c.object_id)) + N'.' + quotename(object_name(c.object_id))`;
113
+ /**
114
+ * What the account may do with each column, as the server's own privilege
115
+ * check answers it (0027): spliced into the columns query, so every column
116
+ * the list holds has an answer -- never a column without one, which
117
+ * `createSnapshot` would refuse as taken before 0027. That is all one read
118
+ * buys. HAS_PERMS_BY_NAME looks the column up by name when it runs, and a
119
+ * name that does not exist answers 0, not NULL: a concurrent rename can
120
+ * therefore report a granted column as not granted (measured in review on
121
+ * mssql/server:2022-latest, 2026-10-09: 4 wrong of 394 checks run against
122
+ * 400 sp_rename round trips). That fails closed -- the
123
+ * form leaves out or does not write a column it could have -- and drift
124
+ * review sees the column's access come back at the next snapshot.
125
+ *
126
+ * SELECT and UPDATE are asked per column, because SQL Server grants and
127
+ * denies both per column, and HAS_PERMS_BY_NAME applies the precedence the
128
+ * server applies: a column DENY under a table GRANT is a no, a column GRANT
129
+ * under a table DENY a yes, and a later table GRANT removes an earlier column
130
+ * DENY (each measured against a real read, B8). INSERT is asked of the object:
131
+ * the server refuses a column list on an INSERT grant or deny (1020), so every
132
+ * column of an object the account may insert into is insertable.
133
+ *
134
+ * The column is QUOTENAME'd too. HAS_PERMS_BY_NAME parses the sub-securable
135
+ * as an identifier: `group` happens to answer either way, `it's [odd]` raw is
136
+ * NULL and quoted is the right answer (B7). A misparse is not always NULL --
137
+ * a column that does not exist answers 0 -- so quoting is the defence, and
138
+ * the NULL check below catches only the misparse that shows.
139
+ */
140
+ const ACCESS_COLUMNS = `${OBJECT} as access_object,
141
+ has_perms_by_name(${OBJECT}, N'OBJECT', N'SELECT', quotename(c.name), N'COLUMN') as can_select,
142
+ has_perms_by_name(${OBJECT}, N'OBJECT', N'INSERT') as can_insert,
143
+ has_perms_by_name(${OBJECT}, N'OBJECT', N'UPDATE', quotename(c.name), N'COLUMN') as can_update`;
144
+ function held(row, capability, answer) {
145
+ if (answer === null) throw new Error(`HAS_PERMS_BY_NAME could not resolve column ${row.name} of ${row.access_object ?? "(unnamed object)"} for ${capability.toUpperCase()}`);
146
+ return answer === 1;
147
+ }
148
+ /** One column's access from the row the columns query returned. */
149
+ function toAccess(row) {
150
+ return {
151
+ select: held(row, "select", row.can_select),
152
+ insert: held(row, "insert", row.can_insert),
153
+ update: held(row, "update", row.can_update)
154
+ };
155
+ }
156
+ /**
157
+ * Whether the account may DELETE from an object of sys.objects `o` in
158
+ * schema `s`, as the server's own check answers it (0049), spliced into the
159
+ * objects query so every object has an answer. QUOTENAME'd, schema and name
160
+ * each, for the reason `OBJECT` above gives. Grants through a role count as
161
+ * the server counts them, and a DENY wins over a GRANT as it does there.
162
+ *
163
+ * DELETE alone deletes nothing by a key: a WHERE or an OUTPUT that names a
164
+ * column needs SELECT on it too, which the columns' access says. Measured on
165
+ * mcr.microsoft.com/mssql/server:2022-latest (16.0.4295.3), 2026-10-11 (P1):
166
+ * a user with DELETE and no SELECT runs `delete … where 1 = 0` and is refused
167
+ * 229 for `where id = 1`; given SELECT on id, `where id = 1` and `output
168
+ * deleted.id` run, and `output deleted.v` is refused 230. Over every table of
169
+ * the fixture, as the owner, the reader and a writer granted DELETE on order
170
+ * and order_line, each answer here was the one `delete … where 1 = 0` got
171
+ * (`discovery-access.integration.test.ts`); a view that cannot be updated is
172
+ * refused 4405 before any privilege is asked, so a view's answer is its
173
+ * grant's.
174
+ */
175
+ const OBJECT_ACCESS = `quotename(s.name) + N'.' + quotename(o.name) as access_object,
176
+ has_perms_by_name(quotename(s.name) + N'.' + quotename(o.name), N'OBJECT', N'DELETE') as can_delete`;
177
+ /** An object's access from the row the objects query returned. A NULL is a name the check could not resolve: a defect, said, as for a column. */
178
+ function toObjectAccess(row) {
179
+ if (row.can_delete === null) throw new Error(`HAS_PERMS_BY_NAME could not resolve ${row.access_object ?? "(unnamed object)"} for DELETE`);
180
+ return { delete: row.can_delete === 1 };
181
+ }
182
+ //#endregion
183
+ //#region src/discovery/types.ts
184
+ const UNSUPPORTED = { kind: "unsupported" };
185
+ const length = (bytes, bytesPerUnit) => bytes === -1 ? null : bytes / bytesPerUnit;
186
+ const integer = (min, max) => () => ({
187
+ kind: "integer",
188
+ min,
189
+ max
190
+ });
191
+ const decimal = (facts) => ({
192
+ kind: "decimal",
193
+ precision: facts.precision,
194
+ scale: facts.scale
195
+ });
196
+ const timestamp = (withTimeZone) => (facts) => ({
197
+ kind: "timestamp",
198
+ withTimeZone,
199
+ precision: facts.scale
200
+ });
201
+ /** The code page of every UTF-8 collation. */
202
+ const UTF8_CODE_PAGE = 65001;
203
+ /** char and varchar count bytes: of UTF-8 under a UTF-8 collation, of the code page under any other, and a NULL code page is the weaker claim. */
204
+ const byteUnit = (facts) => facts.codePage === UTF8_CODE_PAGE ? "utf8-bytes" : "code-page-bytes";
205
+ /** nchar and nvarchar count UTF-16 code units, two bytes each, under every collation, a UTF-8 one included. */
206
+ const codeUnits = () => "utf16-code-units";
207
+ const text = (unit, bytesPerUnit, fixedLength) => (facts) => ({
208
+ kind: "text",
209
+ maxLength: length(facts.maxLength, bytesPerUnit),
210
+ lengthUnit: unit(facts),
211
+ fixedLength
212
+ });
213
+ const binary = (fixedLength) => (facts) => ({
214
+ kind: "binary",
215
+ maxLength: length(facts.maxLength, 1),
216
+ fixedLength
217
+ });
218
+ /**
219
+ * The system types this adapter has a normalised meaning for. Anything else --
220
+ * xml, sql_variant, the CLR types, the deprecated text/ntext/image, a type a
221
+ * later server adds -- is `unsupported`: reported, never dropped (0004).
222
+ *
223
+ * The lengths are the catalog's BYTES turned into the unit the type counts,
224
+ * and the unit is said (0026): nchar and nvarchar store UTF-16 code units, two
225
+ * bytes each, so nvarchar(200) is 400 in sys.columns and 200 here. char and
226
+ * varchar count bytes -- of UTF-8 under a UTF-8 collation, where é is two,
227
+ * and of the collation's code page under any other, which is one per
228
+ * character on 1252 and one or two on 932.
229
+ *
230
+ * binary(n) is fixed: it pads a shorter value with zero bytes to n, so 0x01
231
+ * written to binary(4) reads 0x01000000. varbinary keeps what it is given.
232
+ *
233
+ * Temporal precision is the catalog's scale for every temporal type, which is
234
+ * the number of fractional-second digits the server writes. For datetime that
235
+ * is 3 though it keeps 1/300 s, and for smalldatetime 0 though it keeps whole
236
+ * minutes; the contract has no way to say either, and 0007 records the choice.
237
+ *
238
+ * A Map rather than an object, so that a CLR type somebody named `toString`
239
+ * finds nothing here rather than Object.prototype.toString.
240
+ */
241
+ const BY_SYSTEM_TYPE = /* @__PURE__ */ new Map([
242
+ ["bit", () => ({ kind: "boolean" })],
243
+ ["tinyint", integer("0", "255")],
244
+ ["smallint", integer("-32768", "32767")],
245
+ ["int", integer("-2147483648", "2147483647")],
246
+ ["bigint", integer("-9223372036854775808", "9223372036854775807")],
247
+ ["decimal", decimal],
248
+ ["numeric", decimal],
249
+ ["money", decimal],
250
+ ["smallmoney", decimal],
251
+ ["float", () => ({
252
+ kind: "float",
253
+ bits: 64
254
+ })],
255
+ ["real", () => ({
256
+ kind: "float",
257
+ bits: 32
258
+ })],
259
+ ["date", () => ({ kind: "date" })],
260
+ ["time", (facts) => ({
261
+ kind: "time",
262
+ precision: facts.scale
263
+ })],
264
+ ["datetime2", timestamp(false)],
265
+ ["datetimeoffset", timestamp(true)],
266
+ ["datetime", timestamp(false)],
267
+ ["smalldatetime", timestamp(false)],
268
+ ["char", text(byteUnit, 1, true)],
269
+ ["varchar", text(byteUnit, 1, false)],
270
+ ["nchar", text(codeUnits, 2, true)],
271
+ ["nvarchar", text(codeUnits, 2, false)],
272
+ ["uniqueidentifier", () => ({ kind: "uuid" })],
273
+ ["binary", binary(true)],
274
+ ["varbinary", binary(false)],
275
+ ["timestamp", () => ({ kind: "rowversion" })]
276
+ ]);
277
+ /** The suffix a system type is declared with, from the catalog's numbers. */
278
+ function suffix(systemName, facts) {
279
+ switch (systemName) {
280
+ case "char":
281
+ case "varchar":
282
+ case "binary":
283
+ case "varbinary": return `(${facts.maxLength === -1 ? "max" : String(facts.maxLength)})`;
284
+ case "nchar":
285
+ case "nvarchar": return `(${facts.maxLength === -1 ? "max" : String(facts.maxLength / 2)})`;
286
+ case "decimal":
287
+ case "numeric": return `(${String(facts.precision)},${String(facts.scale)})`;
288
+ case "time":
289
+ case "datetime2":
290
+ case "datetimeoffset": return `(${String(facts.scale)})`;
291
+ default: return "";
292
+ }
293
+ }
294
+ /**
295
+ * How a person would recognise the declared type: `nvarchar(200)`, an alias
296
+ * type by its qualified name, `sysname` as itself, and `rowversion` rather
297
+ * than the catalog's `timestamp`, which reads as a clock and is not one.
298
+ */
299
+ function spell(facts, systemName) {
300
+ if (facts.typeName === null) return systemName === null ? "unknown" : systemName + suffix(systemName, facts);
301
+ if (facts.isUserDefined === true) return facts.typeSchema === null ? facts.typeName : `${facts.typeSchema}.${facts.typeName}`;
302
+ if (facts.typeName === "timestamp") return "rowversion";
303
+ if (facts.typeName !== systemName) return facts.typeName;
304
+ return facts.typeName + suffix(facts.typeName, facts);
305
+ }
306
+ /**
307
+ * A column's type, normalised from what this account can see of it.
308
+ *
309
+ * The system type comes from the column's system_type_id, which is visible
310
+ * even when the declared user-defined type is not: a type is a securable of
311
+ * its own, and an account may read a table without any permission on the type
312
+ * of one of its columns. Normalising from the declared type would lose that
313
+ * column's meaning; joining to it would lose the column.
314
+ */
315
+ function normalizeType(facts) {
316
+ const systemName = facts.systemName ?? facts.typeName;
317
+ const normalize = systemName === null ? void 0 : BY_SYSTEM_TYPE.get(systemName);
318
+ return {
319
+ databaseType: spell(facts, systemName),
320
+ type: normalize === void 0 ? UNSUPPORTED : normalize(facts),
321
+ hidden: facts.typeName === null
322
+ };
323
+ }
324
+ //#endregion
325
+ //#region src/discovery/columns.ts
326
+ /**
327
+ * Columns, their types, how they are generated, their defaults and comments.
328
+ *
329
+ * Every join is a LEFT join, because each joined row is something this account
330
+ * may not be allowed to see while it may see the column: the declared type
331
+ * (a user-defined type is a securable of its own), the default's definition,
332
+ * the comment. An inner join would drop the column, and a dropped column reads
333
+ * as a table that never had it.
334
+ *
335
+ * A default is read through OBJECT_DEFINITION rather than
336
+ * sys.default_constraints, because a default bound with the deprecated
337
+ * sp_bindefault is a default too and is not a constraint. Both are NULL
338
+ * without VIEW DEFINITION. Identity and computed come from sys.columns' own
339
+ * flags: sys.identity_columns and sys.computed_columns add the seed, the
340
+ * increment and the expression, none of which the contract carries.
341
+ *
342
+ * The code page is read from the column's own collation, never the
343
+ * database's: a column may be declared under any collation, and the fixture's
344
+ * UTF-8 varchar sits in a database whose default is code page 1252. It says
345
+ * what a char or varchar length counts (0026).
346
+ *
347
+ * And what the account may do with each column, in the same statement
348
+ * (access.ts, 0027).
349
+ */
350
+ const SQL$1 = (scoped) => `
351
+ select ${COLUMN_DEFINITION},
352
+ object_name(c.default_object_id) as default_name,
353
+ cast(ep.value as nvarchar(max)) as comment,
354
+ ${ACCESS_COLUMNS}
355
+ from ${COLUMN_SOURCE}
356
+ left join sys.extended_properties ep
357
+ on ep.class = 1 and ep.major_id = c.object_id and ep.minor_id = c.column_id and ep.name = N'MS_Description'
358
+ where c.object_id in ${scoped}
359
+ order by c.object_id, c.column_id`;
360
+ /**
361
+ * What a column's definition is read from, of sys.columns `c`: its type, how
362
+ * it is generated, its default. Shared with the root's definition (0041),
363
+ * which reads exactly these, so a fact added here is the definition's too;
364
+ * discovery adds the access, the comment and the default's name.
365
+ */
366
+ const COLUMN_DEFINITION = `c.object_id, c.column_id, c.name,
367
+ t.name as type_name, schema_name(t.schema_id) as type_schema, t.is_user_defined,
368
+ st.name as system_name,
369
+ c.max_length, c.precision, c.scale,
370
+ convert(int, collationproperty(c.collation_name, 'CodePage')) as code_page,
371
+ c.is_nullable, c.is_identity, c.is_computed, c.generated_always_type,
372
+ c.default_object_id,
373
+ object_definition(c.default_object_id) as default_definition`;
374
+ const COLUMN_SOURCE = `sys.columns c
375
+ left join sys.types t on t.user_type_id = c.user_type_id
376
+ left join sys.types st on st.user_type_id = c.system_type_id`;
377
+ /** The name sys.types gives rowversion's system type: its deprecated synonym. */
378
+ const ROWVERSION_SYSTEM_TYPE = "timestamp";
379
+ /** One bracketed name as SQL Server stores it in a definition, a `]` inside it doubled. */
380
+ const BRACKETED = String.raw`\[(?:[^\]]|\]\])+\]`;
381
+ /**
382
+ * A default that is exactly the next value of one sequence, as SQL Server
383
+ * stores the definition: `(NEXT VALUE FOR [schema].[name])`, however it was
384
+ * written. One that computes with the value has more around it.
385
+ */
386
+ const NEXT_VALUE = new RegExp(String.raw`^\(NEXT VALUE FOR (?:${BRACKETED}\.){0,2}${BRACKETED}\)$`, "i");
387
+ /**
388
+ * How the column gets its value.
389
+ *
390
+ * IDENTITY is `identity-always`: an insert that names it is refused (544)
391
+ * unless IDENTITY_INSERT is on, which needs ALTER on the table, and an update
392
+ * of it is refused outright (8102). SQL Server has no BY DEFAULT identity; a
393
+ * default of NEXT VALUE FOR a sequence is what takes its place, and behaves as
394
+ * PostgreSQL's does: it numbers a row an insert leaves out, takes a value
395
+ * given to it, and a value given by hand collides with the sequence later
396
+ * (2627, measured). It is `identity-by-default`, and keeps its default (0026).
397
+ * Its definition is read without VIEW DEFINITION only as NULL, and then the
398
+ * column cannot be told from an ordinary default: it is `none`, and the
399
+ * snapshot's `defaults` gap says the definition was hidden.
400
+ *
401
+ * A period column (GENERATED ALWAYS AS ROW START or END) is written by the
402
+ * database and refused in an insert, which is what `computed` tells a form
403
+ * generator; `none` would offer it as an input.
404
+ */
405
+ function generation(row) {
406
+ if (row.is_identity) return "identity-always";
407
+ if (row.system_name === ROWVERSION_SYSTEM_TYPE) return "rowversion";
408
+ if (row.is_computed || row.generated_always_type !== 0) return "computed";
409
+ if (row.default_definition !== null && NEXT_VALUE.test(row.default_definition)) return "identity-by-default";
410
+ return "none";
411
+ }
412
+ /**
413
+ * A column as its definition describes it, without access, comment or gaps:
414
+ * what the root's description reports of it (0041), and what discovery
415
+ * reports with those added.
416
+ */
417
+ function describedColumn(row) {
418
+ const { databaseType, type } = typed(row);
419
+ return {
420
+ name: row.name,
421
+ ordinal: row.column_id,
422
+ databaseType,
423
+ type,
424
+ nullable: row.is_nullable,
425
+ hasDefault: row.default_object_id !== 0,
426
+ defaultExpression: row.default_definition,
427
+ generated: generation(row)
428
+ };
429
+ }
430
+ function typed(row) {
431
+ return normalizeType({
432
+ typeName: row.type_name,
433
+ typeSchema: row.type_schema,
434
+ isUserDefined: row.is_user_defined,
435
+ systemName: row.system_name,
436
+ maxLength: row.max_length,
437
+ precision: row.precision,
438
+ scale: row.scale,
439
+ codePage: row.code_page
440
+ });
441
+ }
442
+ function toColumn(row, gaps) {
443
+ const column = describedColumn(row);
444
+ if (typed(row).hidden) gaps.push({
445
+ objectId: row.object_id,
446
+ aspect: "columns",
447
+ detail: column.type.kind === "unsupported" ? `${row.name}: its type is hidden from this account, so it is reported as unsupported` : `${row.name}: its user-defined type is hidden from this account, so it is reported as its base type ${column.databaseType}`
448
+ });
449
+ if (column.hasDefault && row.default_definition === null) gaps.push({
450
+ objectId: row.object_id,
451
+ aspect: "defaults",
452
+ detail: `${row.name}: the definition of its default ${row.default_name ?? "(unnamed)"} is hidden without VIEW DEFINITION`
453
+ });
454
+ return {
455
+ ...column,
456
+ comment: row.comment,
457
+ access: toAccess(row)
458
+ };
459
+ }
460
+ async function readColumns(pool, schemas) {
461
+ const rows = await queryScope(pool, schemas, SQL$1);
462
+ const gaps = [];
463
+ const byObject = /* @__PURE__ */ new Map();
464
+ for (const [objectId, group] of groupBy(rows, byObjectId)) byObject.set(objectId, group.map((row) => toColumn(row, gaps)));
465
+ return {
466
+ byObject,
467
+ gaps
468
+ };
469
+ }
470
+ //#endregion
471
+ //#region src/discovery/foreign-keys.ts
472
+ /**
473
+ * Foreign keys, one row per column pair, in constraint_column_id order -- the
474
+ * pairing of a composite key.
475
+ *
476
+ * The target is LEFT joined, because what SQL Server shows an account that
477
+ * may read the referencing table and not the referenced one is precise and
478
+ * partial: the key, its flags, its own columns, and a referenced_object_id
479
+ * whose OBJECT_NAME is NULL and whose columns COL_NAME cannot name. An inner
480
+ * join would drop the key -- "no relationship", the one wrong answer -- so the
481
+ * key is reported with an unknown target and a gap says why.
482
+ */
483
+ const FOREIGN_KEYS_SQL = (scoped) => `
484
+ select fk.parent_object_id as object_id, fk.object_id as key_id, fk.name, fk.is_disabled, fk.is_not_trusted,
485
+ fk.delete_referential_action, fk.update_referential_action,
486
+ rs.name as referenced_schema, ro.name as referenced_name,
487
+ pc.name as column_name, rc.name as referenced_column
488
+ from sys.foreign_keys fk
489
+ join sys.foreign_key_columns fkc on fkc.constraint_object_id = fk.object_id
490
+ join sys.columns pc on pc.object_id = fkc.parent_object_id and pc.column_id = fkc.parent_column_id
491
+ left join sys.objects ro on ro.object_id = fk.referenced_object_id
492
+ left join sys.schemas rs on rs.schema_id = ro.schema_id
493
+ left join sys.columns rc on rc.object_id = fkc.referenced_object_id and rc.column_id = fkc.referenced_column_id
494
+ where fk.parent_object_id in ${scoped}
495
+ order by fk.parent_object_id, fk.object_id, fkc.constraint_column_id`;
496
+ /** sys.foreign_keys' *_referential_action codes. SQL Server has no RESTRICT. */
497
+ const ACTIONS = [
498
+ "no-action",
499
+ "cascade",
500
+ "set-null",
501
+ "set-default"
502
+ ];
503
+ function action(code, name) {
504
+ const found = ACTIONS[code];
505
+ if (found === void 0) throw new Error(`${name} has referential action ${String(code)}, which this adapter does not know`);
506
+ return found;
507
+ }
508
+ /**
509
+ * The target, or `null` when any part of it is hidden: a schema and a name
510
+ * and every paired column, or nothing. Half a target -- a table without its
511
+ * columns -- would pair the key with the wrong columns downstream.
512
+ */
513
+ function target(rows) {
514
+ const [first] = rows;
515
+ const columns = [];
516
+ for (const row of rows) {
517
+ if (row.referenced_column === null) return null;
518
+ columns.push(row.referenced_column);
519
+ }
520
+ if (first.referenced_schema === null || first.referenced_name === null) return null;
521
+ return {
522
+ table: {
523
+ schema: first.referenced_schema,
524
+ name: first.referenced_name
525
+ },
526
+ columns
527
+ };
528
+ }
529
+ function toForeignKey(rows, gaps) {
530
+ const [first] = rows;
531
+ const references = target(rows);
532
+ if (references === null) gaps.push({
533
+ objectId: first.object_id,
534
+ aspect: "foreign-keys",
535
+ detail: `${first.name} references a table this account cannot see: the key and its own columns are visible, the name of its target and of the target's columns are not`
536
+ });
537
+ return {
538
+ name: first.name,
539
+ columns: rows.map((row) => row.column_name),
540
+ references,
541
+ onUpdate: action(first.update_referential_action, first.name),
542
+ onDelete: action(first.delete_referential_action, first.name),
543
+ enforced: !first.is_disabled,
544
+ validated: !first.is_not_trusted
545
+ };
546
+ }
547
+ async function readForeignKeys(pool, schemas) {
548
+ const rows = await queryScope(pool, schemas, FOREIGN_KEYS_SQL);
549
+ const gaps = [];
550
+ const byObject = /* @__PURE__ */ new Map();
551
+ for (const [objectId, group] of groupBy(rows, byObjectId)) byObject.set(objectId, foreignKeysOf(group, gaps));
552
+ return {
553
+ byObject,
554
+ gaps
555
+ };
556
+ }
557
+ /** One object's foreign keys from its rows, in the query's order: discovery's, and the root's description's (0041), which keeps no gaps. */
558
+ function foreignKeysOf(rows, gaps) {
559
+ return [...groupBy(rows, (row) => row.key_id).values()].map((keyRows) => toForeignKey(keyRows, gaps));
560
+ }
561
+ //#endregion
562
+ //#region src/discovery/keys.ts
563
+ /**
564
+ * Primary keys and unique constraints, each in its key order.
565
+ *
566
+ * key_ordinal, never column_id: a composite key declared (tenant_id,
567
+ * customer_no) over columns created the other way round pairs by the key's
568
+ * order, and a foreign key into it pairs by the same. Ordered by numbers only,
569
+ * so the database's collation never decides an order here.
570
+ *
571
+ * Unique constraints only, not unique indexes: a unique index may be filtered,
572
+ * and a filtered index is not a key.
573
+ */
574
+ const KEYS_SQL = (scoped) => `
575
+ select kc.parent_object_id as object_id, kc.object_id as key_id, kc.name,
576
+ cast(case kc.type when 'PK' then 1 else 0 end as bit) as is_primary_key,
577
+ c.name as column_name
578
+ from sys.key_constraints kc
579
+ join sys.index_columns ic on ic.object_id = kc.parent_object_id and ic.index_id = kc.unique_index_id
580
+ join sys.columns c on c.object_id = ic.object_id and c.column_id = ic.column_id
581
+ where kc.parent_object_id in ${scoped} and ic.key_ordinal > 0
582
+ order by kc.parent_object_id, kc.object_id, ic.key_ordinal`;
583
+ /**
584
+ * Each table's keys, by object_id. Keys of a table this account can see are
585
+ * visible with it, so there is never a gap to report here.
586
+ */
587
+ async function readKeys(pool, schemas) {
588
+ const rows = await queryScope(pool, schemas, KEYS_SQL);
589
+ const byObject = /* @__PURE__ */ new Map();
590
+ for (const [objectId, group] of groupBy(rows, byObjectId)) byObject.set(objectId, keysOf(group));
591
+ return byObject;
592
+ }
593
+ /** One object's keys from its rows, in the query's order: discovery's, and the root's description's (0041). */
594
+ function keysOf(rows) {
595
+ const keys = {
596
+ primaryKey: null,
597
+ uniqueKeys: []
598
+ };
599
+ for (const keyRows of groupBy(rows, (row) => row.key_id).values()) {
600
+ const [first] = keyRows;
601
+ const key = {
602
+ name: first.name,
603
+ columns: keyRows.map((row) => row.column_name)
604
+ };
605
+ if (first.is_primary_key) keys.primaryKey = key;
606
+ else keys.uniqueKeys.push(key);
607
+ }
608
+ return keys;
609
+ }
610
+ //#endregion
611
+ //#region src/discovery/objects.ts
612
+ /**
613
+ * The tables and views in scope, with their MS_Description and whether the
614
+ * account may delete from each (access.ts, 0049).
615
+ *
616
+ * Only what this account holds some permission on: SQL Server filters
617
+ * sys.objects without a word. Whether that filter can have hidden anything is
618
+ * the schemas concern's question, not this one's.
619
+ *
620
+ * Comments are extended properties, and an account that can see the object
621
+ * can see them: no gap is needed for comments, which was found by asking a
622
+ * real server as a reader with SELECT only.
623
+ */
624
+ /** An object's kind, of sys.objects `o`: discovery's mapping, and the root's definition's (0041). `o.type` is char(2), so 'V' is compared as 'V '. */
625
+ const OBJECT_KIND = `case o.type when 'V' then 'view' else 'table' end`;
626
+ const SQL = (scoped) => `
627
+ select o.object_id, s.name as schema_name, o.name as object_name,
628
+ ${OBJECT_KIND} as kind,
629
+ cast(ep.value as nvarchar(max)) as comment,
630
+ ${OBJECT_ACCESS}
631
+ from sys.objects o
632
+ join sys.schemas s on s.schema_id = o.schema_id
633
+ left join sys.extended_properties ep
634
+ on ep.class = 1 and ep.major_id = o.object_id and ep.minor_id = 0 and ep.name = N'MS_Description'
635
+ where o.object_id in ${scoped}`;
636
+ /** Each object in scope, by object_id, with nothing attached yet. */
637
+ async function readObjects(pool, schemas) {
638
+ const rows = await queryScope(pool, schemas, SQL);
639
+ return new Map(rows.map((row) => [row.object_id, {
640
+ ref: {
641
+ schema: row.schema_name,
642
+ name: row.object_name
643
+ },
644
+ kind: row.kind,
645
+ comment: row.comment,
646
+ columns: [],
647
+ primaryKey: null,
648
+ uniqueKeys: [],
649
+ foreignKeys: [],
650
+ checks: [],
651
+ access: toObjectAccess(row)
652
+ }]));
653
+ }
654
+ //#endregion
655
+ //#region src/discovery/visibility.ts
656
+ /**
657
+ * Whether this account can see every object in each schema of the scope.
658
+ *
659
+ * SQL Server lists in sys.objects only the securables an account holds some
660
+ * permission on, so a table the reader may not touch is simply not there.
661
+ * VIEW DEFINITION on the schema is what lifts that filter -- on its own,
662
+ * without any data access -- so a schema where the account lacks it gets a
663
+ * gap: the object list may be short, and nothing in it says by how much.
664
+ *
665
+ * sys.schemas itself is not filtered: an account sees every schema, including
666
+ * ones it holds nothing in. A scope name with no schema behind it is therefore
667
+ * established as absent and needs no gap. The gap names the schema as the
668
+ * catalog spells it, which on a case-insensitive database may not be how the
669
+ * scope does: `SALES` finds `sales`, and the gap is about `sales` (0027).
670
+ *
671
+ * HAS_PERMS_BY_NAME parses the name it is given, so it gets QUOTENAME's
672
+ * output: given `it's [odd]` raw it returns NULL, which would read as "may not
673
+ * see" for a schema the account can see perfectly well.
674
+ */
675
+ const SCHEMAS_SQL = (_scoped, schemaList) => `
676
+ select s.name, has_perms_by_name(quotename(s.name), 'SCHEMA', 'VIEW DEFINITION') as can_view_definitions
677
+ from sys.schemas s
678
+ where s.name in (${schemaList})`;
679
+ /**
680
+ * Who a DENY row binds: the account itself, or a role it is in -- which is how
681
+ * a DBA usually denies, and a row that names the role.
682
+ */
683
+ const BINDS_ME = `(p.grantee_principal_id = database_principal_id() or is_member(user_name(p.grantee_principal_id)) = 1)`;
684
+ /**
685
+ * Objects this account is denied VIEW DEFINITION on, which therefore vanish
686
+ * from sys.objects even under a schema-level grant that HAS_PERMS_BY_NAME
687
+ * still reports. The account can read its own DENY rows -- made to it or to a
688
+ * role it is in -- but not the denied object's name or schema, so they are
689
+ * counted rather than placed: any of them may be in scope. CONTROL is counted
690
+ * too, because denying it denies everything it implies.
691
+ *
692
+ * A condition on `sys.database_permissions p`, exported, as `DENIED_SCHEMAS`
693
+ * and `VIEWS_DATABASE` are, for the delete's guard (0050): the batch that
694
+ * refuses a delete its account can no longer see every key of, and the
695
+ * snapshot's `referenced-by` gap, read the one condition.
696
+ */
697
+ const DENIED_OBJECTS = `p.class = 1 and p.state = 'D' and p.permission_name in ('VIEW DEFINITION', 'CONTROL')
698
+ and ${BINDS_ME}
699
+ and object_name(p.major_id) is null`;
700
+ const DENIED_OBJECTS_SQL = `
701
+ select count(*) as hidden
702
+ from sys.database_permissions p
703
+ where ${DENIED_OBJECTS}`;
704
+ /**
705
+ * Schemas this account is denied VIEW DEFINITION or CONTROL on (class 3).
706
+ * A schema in scope already answers for itself through HAS_PERMS_BY_NAME
707
+ * above; this count is for row security, because a security policy lives in
708
+ * a schema of its own and may filter a table in any other. Measured (B10f):
709
+ * with VIEW DEFINITION on the database and a deny on the policy's schema, the
710
+ * database check still says yes, the policy is not listed, and the object
711
+ * count above is 0 -- so without this count, a policy that applies would read
712
+ * as none.
713
+ *
714
+ * Only a deny the server applies is counted. A DENY binds no sysadmin and no
715
+ * dbo, yet one made to `public` matches BINDS_ME for them through IS_MEMBER,
716
+ * and the owner, who sees every policy, would be told it is denied a schema.
717
+ * HAS_PERMS_BY_NAME applies the exemption and the deny's precedence, so a
718
+ * schema whose definitions the account can still see is not counted.
719
+ */
720
+ const DENIED_SCHEMAS = `p.class = 3 and p.state = 'D' and p.permission_name in ('VIEW DEFINITION', 'CONTROL')
721
+ and ${BINDS_ME}
722
+ and has_perms_by_name(quotename(schema_name(p.major_id)), N'SCHEMA', N'VIEW DEFINITION') = 0`;
723
+ const DENIED_SCHEMAS_SQL = `
724
+ select count(*) as hidden
725
+ from sys.database_permissions p
726
+ where ${DENIED_SCHEMAS}`;
727
+ /**
728
+ * Whether the account may view every definition in the database, which is
729
+ * what lists to it every object of every schema -- and so every security
730
+ * policy, which lives in a schema of its own and filters a table in any
731
+ * other (0027, B10b), and every foreign key, which is on a table in any
732
+ * schema and refers to one in scope (0049, P2). VIEW SECURITY DEFINITION
733
+ * shows no policy at all (B10d). The database is QUOTENAME'd:
734
+ * HAS_PERMS_BY_NAME parses it, and in a database named `it's [odd] db` the
735
+ * raw name answers NULL (B9).
736
+ */
737
+ const VIEWS_DATABASE = `has_perms_by_name(quotename(db_name()), N'DATABASE', N'VIEW DEFINITION')`;
738
+ const DATABASE_SQL = `select ${VIEWS_DATABASE} as can_view_definitions`;
739
+ /**
740
+ * Why this account may not see every definition in the database, or nothing
741
+ * when it may: what row security (0027) and what refers to a table (0049)
742
+ * are established by, because a security policy and a foreign key each live
743
+ * on an object of any schema. Established takes VIEW DEFINITION on the
744
+ * database and no VIEW DEFINITION or CONTROL deny on an object or a schema
745
+ * binding the account -- a denied object, or an object in a denied schema,
746
+ * is not listed even under the database grant (B10e, B10f; for a key, P2).
747
+ * `lists` names what the database grant lists, for the sentence.
748
+ */
749
+ function unseenDefinitions(visibility, lists) {
750
+ const reasons = [];
751
+ if (!visibility.canViewDatabase) reasons.push(`this account lacks VIEW DEFINITION on the database, which is what lists every ${lists}`);
752
+ if (visibility.denied.objects > 0) reasons.push(`this account is denied VIEW DEFINITION or CONTROL on ${String(visibility.denied.objects)} object(s)`);
753
+ if (visibility.denied.schemas > 0) reasons.push(`this account is denied VIEW DEFINITION or CONTROL on ${String(visibility.denied.schemas)} schema(s)`);
754
+ return reasons;
755
+ }
756
+ async function readObjectVisibility(pool, schemas) {
757
+ const [rows, deniedObjects, deniedSchemas, database] = await Promise.all([
758
+ queryScope(pool, schemas, SCHEMAS_SQL),
759
+ pool.request().query(DENIED_OBJECTS_SQL),
760
+ pool.request().query(DENIED_SCHEMAS_SQL),
761
+ pool.request().query(DATABASE_SQL)
762
+ ]);
763
+ const gaps = rows.filter((row) => row.can_view_definitions !== 1).map((row) => ({
764
+ subject: {
765
+ kind: "schema",
766
+ schema: row.name
767
+ },
768
+ aspect: "objects",
769
+ detail: `without VIEW DEFINITION on schema ${row.name}, the catalog lists only the tables and views this account holds a permission on, so one it cannot see is absent without trace`
770
+ }));
771
+ const denied = {
772
+ objects: deniedObjects.recordset[0]?.hidden ?? 0,
773
+ schemas: deniedSchemas.recordset[0]?.hidden ?? 0
774
+ };
775
+ if (denied.objects > 0) gaps.push({
776
+ subject: { kind: "scope" },
777
+ aspect: "objects",
778
+ detail: `this account is denied VIEW DEFINITION on ${String(denied.objects)} object(s) it therefore cannot see, and the catalog does not say which schema they are in: any of them may be in scope`
779
+ });
780
+ return {
781
+ gaps,
782
+ denied,
783
+ canViewDatabase: database.recordset[0]?.can_view_definitions === 1
784
+ };
785
+ }
786
+ //#endregion
787
+ //#region src/discovery/referenced-by.ts
788
+ /**
789
+ * Every foreign key the catalog shows that refers to a table in scope, from a
790
+ * table in any schema, one row per column pair in constraint_column_id order
791
+ * (0049). Joined, not LEFT joined: a key is listed to an account exactly when
792
+ * it may see the table the key is on, and then it sees that table's columns,
793
+ * and the referenced table is one in scope; what the account cannot see is
794
+ * the whole key, which the gap below says.
795
+ */
796
+ const INBOUND_SQL = (scoped) => `
797
+ select fk.referenced_object_id as object_id, fk.object_id as key_id, fk.name,
798
+ schema_name(po.schema_id) as referring_schema, po.name as referring_name,
799
+ fk.is_disabled, fk.delete_referential_action, fk.update_referential_action,
800
+ pc.name as column_name, rc.name as referenced_column
801
+ from sys.foreign_keys fk
802
+ join sys.objects po on po.object_id = fk.parent_object_id
803
+ join sys.foreign_key_columns fkc on fkc.constraint_object_id = fk.object_id
804
+ join sys.columns pc on pc.object_id = fkc.parent_object_id and pc.column_id = fkc.parent_column_id
805
+ join sys.columns rc on rc.object_id = fkc.referenced_object_id and rc.column_id = fkc.referenced_column_id
806
+ where fk.referenced_object_id in ${scoped}
807
+ order by fk.referenced_object_id, fk.object_id, fkc.constraint_column_id`;
808
+ function toInbound(rows) {
809
+ const [first] = rows;
810
+ const actionOf = (code) => action(code, first.name);
811
+ return {
812
+ table: {
813
+ schema: first.referring_schema,
814
+ name: first.referring_name
815
+ },
816
+ name: first.name,
817
+ columns: rows.map((row) => row.column_name),
818
+ referencedColumns: rows.map((row) => row.referenced_column),
819
+ onDelete: actionOf(first.delete_referential_action),
820
+ onUpdate: actionOf(first.update_referential_action),
821
+ enforced: !first.is_disabled
822
+ };
823
+ }
824
+ /**
825
+ * Every edge constraint that connects a node table in scope, from an edge
826
+ * table in any schema: one row per constraint and node table, whichever end
827
+ * of a clause the node is.
828
+ *
829
+ * An edge constraint is not a foreign key -- sys.foreign_keys does not list
830
+ * it -- yet a delete of a node acts through it. Measured on 16.0.4295.3,
831
+ * 2026-10-11 (`discovery.integration.test.ts`): ON DELETE CASCADE removes
832
+ * the edges that connect the node, from either end, and NO ACTION refuses
833
+ * the delete with 547 while one does. This release describes neither the
834
+ * edge nor its constraint, so each is a `referenced-by` gap on the node.
835
+ * sys.edge_constraints is filtered as sys.foreign_keys is: measured the same
836
+ * day, a constraint on an edge table in another schema is listed under VIEW
837
+ * DEFINITION on the database and not under VIEW DEFINITION on the node's
838
+ * schema alone -- where `referencedByGaps` already gives its scope gap.
839
+ */
840
+ const EDGES_SQL = (scoped) => `
841
+ select distinct n.object_id, ec.name, schema_name(e.schema_id) as edge_schema, e.name as edge_name,
842
+ ec.delete_referential_action_desc as delete_action
843
+ from sys.edge_constraints ec
844
+ join sys.edge_constraint_clauses cl on cl.object_id = ec.object_id
845
+ join sys.objects e on e.object_id = ec.parent_object_id
846
+ join sys.objects n on n.object_id in (cl.from_object_id, cl.to_object_id)
847
+ where n.object_id in ${scoped}`;
848
+ function edgeGap(row) {
849
+ return {
850
+ objectId: row.object_id,
851
+ aspect: "referenced-by",
852
+ detail: `edge constraint ${row.name} of ${row.edge_schema}.${row.edge_name} connects this node table, ON DELETE ${row.delete_action}, so a delete of its rows acts on that table's edges or is refused by them; this release lists foreign keys, not edge constraints`
853
+ };
854
+ }
855
+ /**
856
+ * Every key the catalog shows that refers to each table in scope, by the
857
+ * referenced table's object_id, and a gap on each node table an edge
858
+ * constraint connects.
859
+ */
860
+ async function readReferencedBy(pool, schemas) {
861
+ const [rows, edges] = await Promise.all([queryScope(pool, schemas, INBOUND_SQL), queryScope(pool, schemas, EDGES_SQL)]);
862
+ const byObject = /* @__PURE__ */ new Map();
863
+ for (const [objectId, group] of groupBy(rows, byObjectId)) byObject.set(objectId, [...groupBy(group, (row) => row.key_id).values()].map(toInbound));
864
+ return {
865
+ byObject,
866
+ gaps: edges.map(edgeGap)
867
+ };
868
+ }
869
+ /**
870
+ * The gap that says what refers to a table may be short, or none (0049).
871
+ *
872
+ * SQL Server lists a foreign key only to an account that may see the table
873
+ * the key is on, and that table may be in any schema. Measured on
874
+ * 16.0.4295.3, 2026-10-11 (P2), with a key from another schema onto
875
+ * sales.order: the owner and a database VIEW DEFINITION grantee see it; a
876
+ * grantee of VIEW DEFINITION on sales alone, the reader, and a database
877
+ * grantee denied VIEW DEFINITION on the other schema or on its table do
878
+ * not -- the reader sees not even fk_order_line_order. So the lists are
879
+ * complete exactly where row security is established, by the one rule
880
+ * `unseenDefinitions` holds, and otherwise ONE gap on the scope says why: a
881
+ * key the account cannot see may refer to any table in scope. The gap is
882
+ * there only when some object is described, as row security's is: it
883
+ * explains an answer, and an account that sees nothing gave none.
884
+ */
885
+ function referencedByGaps(described, visibility) {
886
+ const reasons = unseenDefinitions(visibility, "foreign key on a table in any schema");
887
+ if (reasons.length === 0 || described === 0) return [];
888
+ return [{
889
+ subject: { kind: "scope" },
890
+ aspect: "referenced-by",
891
+ detail: `${reasons.join("; ")}, so a key on a table it cannot see may refer to any table in scope`
892
+ }];
893
+ }
894
+ //#endregion
895
+ //#region src/discovery/row-security.ts
896
+ /**
897
+ * The objects in scope an enabled security policy has a predicate on, filter
898
+ * or block. Positive evidence, and it needs nothing more: a policy this
899
+ * account can see that targets a table applies to it, because SQL Server
900
+ * exempts nobody from an enabled policy -- dbo and sysadmin included. A
901
+ * disabled policy filters nothing and is not counted.
902
+ */
903
+ const POLICED_SQL = (scoped) => `
904
+ select distinct pr.target_object_id as object_id
905
+ from sys.security_predicates pr
906
+ join sys.security_policies p on p.object_id = pr.object_id
907
+ where p.is_enabled = 1 and pr.target_object_id in ${scoped}`;
908
+ /** The object_ids in scope that an enabled policy this account can see targets. */
909
+ async function readPolicies(pool, schemas) {
910
+ const policed = await queryScope(pool, schemas, POLICED_SQL);
911
+ return new Set(policed.map((row) => row.object_id));
912
+ }
913
+ /**
914
+ * Each object's row security, and the gap that explains an `unknown` (0027).
915
+ *
916
+ * `applies` where a visible enabled policy targets the object. `none` only
917
+ * where it is established that no policy is hidden, by the rule
918
+ * `unseenDefinitions` holds: a denied policy, or a policy in a denied schema,
919
+ * is not listed even under the database grant (B10e, B10f). Anything else is
920
+ * `unknown`, with ONE gap on the scope: a hidden policy can filter any table,
921
+ * so the doubt cannot be placed on an object. The gap is there only when some
922
+ * object is unknown; it explains an answer, and an account that sees nothing
923
+ * gave none.
924
+ */
925
+ function rowSecurityOf(objectIds, policed, visibility) {
926
+ const reasons = unseenDefinitions(visibility, "security policy");
927
+ const established = reasons.length === 0;
928
+ const of = (objectId) => policed.has(objectId) ? "applies" : established ? "none" : "unknown";
929
+ return {
930
+ of,
931
+ gaps: [...objectIds].some((objectId) => of(objectId) === "unknown") ? [{
932
+ subject: { kind: "scope" },
933
+ aspect: "row-security",
934
+ detail: `${reasons.join("; ")}, so a security policy it cannot see may filter any table or view in scope`
935
+ }] : []
936
+ };
937
+ }
938
+ //#endregion
939
+ //#region src/version.ts
940
+ /**
941
+ * The build the server runs, as it states it ("16.0.4205.1").
942
+ *
943
+ * One query, shared by `ping` and by discovery, so the version a snapshot
944
+ * records and the version a ping reports cannot differ — and so discovery
945
+ * does not import the adapter that imports it. `ProductVersion` rather than
946
+ * `@@VERSION`, which adds the edition, the OS and a paragraph of text. Cast,
947
+ * because `SERVERPROPERTY` returns `sql_variant` and the driver would hand that
948
+ * back as something other than a string.
949
+ */
950
+ async function readServerVersion(pool) {
951
+ const row = (await pool.request().query("select cast(serverproperty('ProductVersion') as nvarchar(128)) as version")).recordset[0];
952
+ if (row === void 0) throw new Error("SQL Server answered the version query with no row");
953
+ return row.version;
954
+ }
955
+ //#endregion
956
+ //#region src/discovery/discover.ts
957
+ /**
958
+ * What this connection's account can see of the tables and views in `scope`,
959
+ * what it may do with each column, whether a security policy filters each
960
+ * object for it -- and, as gaps, what it cannot establish (0007, 0027) --
961
+ * whether it may delete from each, and every key it can see that refers to
962
+ * each, from any schema (0049).
963
+ *
964
+ * Read from SQL Server's catalog views, never INFORMATION_SCHEMA, which carries
965
+ * neither the trust and disable flags of a key nor rowversion nor extended
966
+ * properties (0004). One catalog concern per query, each bound to the scope by
967
+ * parameters; they run side by side and are not one consistent read. A
968
+ * concurrent ALTER can tear the result, in which case createSnapshot refuses
969
+ * what no catalog could produce, or drift review sees the change next time.
970
+ * A column and its privileges are one read (access.ts), so no column lacks
971
+ * an answer; a concurrent rename can still make that answer a false "no".
972
+ *
973
+ * Unlike PostgreSQL's, this catalog is filtered by permission: an object the
974
+ * account holds nothing on is absent, behind a schema gap, a security policy
975
+ * it cannot see leaves row security `unknown`, behind a scope gap, and a key
976
+ * on a table it cannot see is missing from what refers to a table, behind
977
+ * another. A graph edge constraint, which is not a foreign key, is a gap on
978
+ * each node table it connects, for any account (referenced-by.ts).
979
+ *
980
+ * The pool is the caller's. Nothing here opens a connection or reads
981
+ * configuration, and nothing here closes the pool.
982
+ */
983
+ async function discoverSqlServer(pool, scope) {
984
+ const schemas = [...new Set(scope.schemas)];
985
+ const [version, account] = await Promise.all([readServerVersion(pool), readAccount(pool)]);
986
+ if (schemas.length === 0) return createSnapshot({
987
+ kind: "sqlserver",
988
+ serverVersion: version,
989
+ account,
990
+ scope,
991
+ objects: [],
992
+ gaps: []
993
+ });
994
+ const [objects, columns, keys, foreignKeys, referencedBy, checks, visibility, policed] = await Promise.all([
995
+ readObjects(pool, schemas),
996
+ readColumns(pool, schemas),
997
+ readKeys(pool, schemas),
998
+ readForeignKeys(pool, schemas),
999
+ readReferencedBy(pool, schemas),
1000
+ readChecks(pool, schemas),
1001
+ readObjectVisibility(pool, schemas),
1002
+ readPolicies(pool, schemas)
1003
+ ]);
1004
+ const rowSecurity = rowSecurityOf(objects.keys(), policed, visibility);
1005
+ const assembled = [];
1006
+ for (const [objectId, object] of objects) {
1007
+ const objectKeys = keys.get(objectId);
1008
+ assembled.push({
1009
+ ...object,
1010
+ columns: columns.byObject.get(objectId) ?? [],
1011
+ primaryKey: objectKeys?.primaryKey ?? null,
1012
+ uniqueKeys: objectKeys?.uniqueKeys ?? [],
1013
+ foreignKeys: foreignKeys.byObject.get(objectId) ?? [],
1014
+ checks: checks.byObject.get(objectId) ?? [],
1015
+ rowSecurity: rowSecurity.of(objectId),
1016
+ referencedBy: referencedBy.byObject.get(objectId) ?? []
1017
+ });
1018
+ }
1019
+ return createSnapshot({
1020
+ kind: "sqlserver",
1021
+ serverVersion: version,
1022
+ account,
1023
+ scope,
1024
+ objects: assembled,
1025
+ gaps: [
1026
+ ...visibility.gaps,
1027
+ ...rowSecurity.gaps,
1028
+ ...referencedByGaps(objects.size, visibility),
1029
+ ...named$2(objects, [
1030
+ ...columns.gaps,
1031
+ ...foreignKeys.gaps,
1032
+ ...referencedBy.gaps,
1033
+ ...checks.gaps
1034
+ ])
1035
+ ]
1036
+ });
1037
+ }
1038
+ /**
1039
+ * Gaps with the object's name in place of its id. A gap on an object the
1040
+ * objects query did not return belongs to a table created between the two
1041
+ * reads, and is left out with the table.
1042
+ */
1043
+ function named$2(objects, gaps) {
1044
+ return gaps.flatMap((gap) => {
1045
+ const object = objects.get(gap.objectId);
1046
+ return object === void 0 ? [] : [{
1047
+ subject: {
1048
+ kind: "object",
1049
+ object: object.ref
1050
+ },
1051
+ aspect: gap.aspect,
1052
+ detail: gap.detail
1053
+ }];
1054
+ });
1055
+ }
1056
+ //#endregion
1057
+ //#region src/adapter.ts
1058
+ /**
1059
+ * SQL Server, through a pool the composition root connected.
1060
+ *
1061
+ * The pool rather than a connection string: opening the connection is where a
1062
+ * secret is handled, and that happens once, in the composition root, where the
1063
+ * secret resolver lives. Nothing in this package reads configuration, and the
1064
+ * tests hand it a pool pointed at a container.
1065
+ *
1066
+ * `ConnectionPool` is in this signature, which is why `@types/mssql` is a
1067
+ * dependency of this package and not a devDependency: a consumer type-checking
1068
+ * against the published declarations needs it, and `skipLibCheck` would not
1069
+ * save them, since the import is in OUR declaration file. The install gate
1070
+ * runs with `skipLibCheck` off for exactly this.
1071
+ */
1072
+ function createSqlServerAdapter(pool) {
1073
+ return {
1074
+ kind: "sqlserver",
1075
+ async ping() {
1076
+ return {
1077
+ kind: "sqlserver",
1078
+ version: await readServerVersion(pool)
1079
+ };
1080
+ },
1081
+ discover: (scope) => discoverSqlServer(pool, scope),
1082
+ async close() {
1083
+ await pool.close();
1084
+ }
1085
+ };
1086
+ }
1087
+ //#endregion
1088
+ //#region src/sql/statement.ts
1089
+ /**
1090
+ * The parameters of one statement as it is built: each value gets the next
1091
+ * generated name, `@p0`, `@p1`, and only that name reaches the SQL text. A
1092
+ * value is never spliced into a statement, whatever it holds.
1093
+ */
1094
+ var Parameters = class {
1095
+ #list = [];
1096
+ /** Binds a value and returns its name, for the SQL being built. */
1097
+ add(type, value) {
1098
+ const name = `p${String(this.#list.length)}`;
1099
+ this.#list.push({
1100
+ name,
1101
+ type,
1102
+ value
1103
+ });
1104
+ return `@${name}`;
1105
+ }
1106
+ statement(sql) {
1107
+ return {
1108
+ sql,
1109
+ parameters: [...this.#list]
1110
+ };
1111
+ }
1112
+ };
1113
+ /** A request for one statement, its values bound. A value the driver will not take is thrown here, before anything is sent. */
1114
+ function prepare(pool, statement) {
1115
+ const request = pool.request();
1116
+ for (const parameter of statement.parameters) request.input(parameter.name, parameter.type, parameter.value);
1117
+ return request;
1118
+ }
1119
+ /**
1120
+ * Sends a prepared statement and returns its last result set: a write batch
1121
+ * selects what it wrote after its own bookkeeping, and a read selects once.
1122
+ *
1123
+ * Every statement travels as `sp_executesql`, with or without parameters —
1124
+ * `tedious` sends a query no other way — which is what keeps the values out of
1125
+ * the SQL text, and what ends a `SET` with the request. What this rejects
1126
+ * with is always the driver's.
1127
+ */
1128
+ async function send(request, statement) {
1129
+ return [...(await request.query(statement.sql)).recordsets.at(-1) ?? []];
1130
+ }
1131
+ /**
1132
+ * Runs one lookup statement until `signal` aborts (0061). Already aborted: it
1133
+ * throws `signal.reason` and builds and sends nothing -- the check that also
1134
+ * stops a resolve between two key groups, since a listener added to a signal
1135
+ * that has already aborted is never called. Aborted while the statement is on
1136
+ * its way or running: `request.cancel()` sends a TDS attention on the
1137
+ * statement's own connection, and this rejects with exactly `signal.reason`
1138
+ * at once, without waiting for the server's acknowledgement. An answer read
1139
+ * before the abort is returned as it came.
1140
+ *
1141
+ * Attention is in-band: tedious waits for the server to acknowledge it
1142
+ * before the connection takes another request, so a cancel can only ever
1143
+ * stop its own statement. Measured on 2026-10-14 against SQL Server 2022
1144
+ * with mssql 12.7.4 and tedious 20.3.3: of 300 trials, the 99 cancels sent
1145
+ * as the statement completed reached 0 next requests on the connection. An
1146
+ * abort before mssql holds a connection marks the request cancelled, and
1147
+ * mssql gives the connection back without sending the statement.
1148
+ *
1149
+ * The driver's own rejection of a cancelled statement, ECANCEL, comes after
1150
+ * this has rejected, and is dropped: by then nobody is waiting for it. The
1151
+ * connection goes back to the pool when it arrives.
1152
+ *
1153
+ * No `requestTimeout` is set here: the deadline is the caller's signal, one
1154
+ * timer the same on both engines that also counts the wait for a pooled
1155
+ * connection, which tedious's timer, started at send, does not. mssql's
1156
+ * default request timeout of 15 s, which it hands tedious for every request
1157
+ * (lib/tedious/connection-pool.js), still bounds every statement, a
1158
+ * lookup's included.
1159
+ */
1160
+ function runUntil(pool, statement, signal) {
1161
+ stopIfAborted(signal);
1162
+ const request = prepare(pool, statement);
1163
+ const answer = send(request, statement);
1164
+ return new Promise((resolve, reject) => {
1165
+ const stop = () => {
1166
+ request.cancel();
1167
+ reject(signal.reason);
1168
+ };
1169
+ signal.addEventListener("abort", stop, { once: true });
1170
+ answer.then((rows) => {
1171
+ signal.removeEventListener("abort", stop);
1172
+ resolve(rows);
1173
+ }, (error) => {
1174
+ signal.removeEventListener("abort", stop);
1175
+ reject(error);
1176
+ });
1177
+ });
1178
+ }
1179
+ /** Throws `signal.reason` when the caller has already gone, so nothing is built or sent for it. */
1180
+ function stopIfAborted(signal) {
1181
+ if (signal.aborted) throw signal.reason;
1182
+ }
1183
+ //#endregion
1184
+ //#region src/sql/values.ts
1185
+ /**
1186
+ * How a value crosses between SQL Server and the API, in both directions, for
1187
+ * every kind the contract names. One module for the lookup half and the record
1188
+ * half, because two spellings of one decimal inside one adapter is the
1189
+ * divergence the core exists to prevent.
1190
+ *
1191
+ * Out of the database, every value is converted to text BY THE SERVER, into
1192
+ * exactly what `codecFor(column).parse` returns, and the driver only ever
1193
+ * carries a string. `tedious` returns `decimal(18,4)` as a lossy JavaScript
1194
+ * number and `date` as midnight UTC (0007); a string it cannot misread.
1195
+ *
1196
+ * Into the database, every value except a boolean and a float travels as
1197
+ * nvarchar text and is converted by the server to the column's type. Not
1198
+ * through the driver's typed parameters: `tedious` binds a `Decimal` parameter
1199
+ * through a JavaScript number, so `1234567890123.4567` is stored as
1200
+ * `1234567890123.4568` (the records suite holds it), and the largest
1201
+ * `decimal(18,4)` is refused outright (8023, measured once on 2026-10-09).
1202
+ */
1203
+ /** SQL Server's largest decimal precision: decimal(38, s). */
1204
+ const MAX_PRECISION = 38;
1205
+ /** SQL Server's integer types, narrowest first, by the range the snapshot gives each. */
1206
+ const INTEGER_TYPES = [
1207
+ [
1208
+ "tinyint",
1209
+ 0n,
1210
+ 255n
1211
+ ],
1212
+ [
1213
+ "smallint",
1214
+ -32768n,
1215
+ 32767n
1216
+ ],
1217
+ [
1218
+ "int",
1219
+ -2147483648n,
1220
+ 2147483647n
1221
+ ],
1222
+ [
1223
+ "bigint",
1224
+ -9223372036854775808n,
1225
+ 9223372036854775807n
1226
+ ]
1227
+ ];
1228
+ /** The integer type a column of this range is: a key compared with its own type is a seek, not a conversion of every row. */
1229
+ function integerType(type) {
1230
+ const min = BigInt(type.min);
1231
+ const max = BigInt(type.max);
1232
+ const found = INTEGER_TYPES.find(([, low, high]) => low <= min && max <= high);
1233
+ if (found === void 0) throw new Error(`No SQL Server integer type holds ${type.min} to ${type.max}`);
1234
+ return found[0];
1235
+ }
1236
+ /**
1237
+ * The decimal type a value is converted to, spliced into the statement
1238
+ * because SQL Server takes no parameter there. So the precision and the scale
1239
+ * are checked to be what its syntax allows — whole numbers, 1 to 38 and 0 to
1240
+ * the precision — before they become SQL: they come from approved bindings,
1241
+ * and a bundle altered where it is stored must not run as a statement.
1242
+ */
1243
+ function decimalType(type) {
1244
+ const { precision, scale } = type;
1245
+ if (precision === null || scale === null) throw new Error("A SQL Server decimal has a precision and a scale");
1246
+ if (!Number.isSafeInteger(precision) || !Number.isSafeInteger(scale) || precision < 1 || precision > MAX_PRECISION || scale < 0 || scale > precision) throw new Error(`A SQL Server decimal's precision is 1 to ${String(MAX_PRECISION)} and its scale 0 to its precision`);
1247
+ return `decimal(${String(precision)}, ${String(scale)})`;
1248
+ }
1249
+ function noValue(type) {
1250
+ return /* @__PURE__ */ new Error(`A ${type.kind} column has no canonical API value, so it can be neither read nor written as one`);
1251
+ }
1252
+ /**
1253
+ * SQL that reads `expression` as the text of its canonical API value.
1254
+ *
1255
+ * - text: itself, as nvarchar(max), so a `varchar` is decoded by the server's
1256
+ * code page and not by the driver's. A char(n) or nchar(n) without its
1257
+ * padding: SQL Server keeps it through the conversion (`AB ` for a char(3)
1258
+ * holding AB, C1) and PostgreSQL's bpchar drops it, so a fixed-length key
1259
+ * would otherwise have one token per engine (0028). `rtrim` removes U+0020
1260
+ * and nothing else — a no-break space or a tab stays (C1b) — which is
1261
+ * exactly the padding. Measured on SQL Server 2022 (16.0.4295) on 2026-10-09.
1262
+ * - integer: the decimal string, whatever its width.
1263
+ * - decimal: every digit the column holds, padded to its scale, in style 2 —
1264
+ * which a decimal ignores, and which gives `money` its four places where
1265
+ * its default conversion rounds to two.
1266
+ * - boolean: `1` or `0`, which `fromCanonicalText` makes `true` or `false`.
1267
+ * - float: style 3, seventeen significant digits, which a double round-trips
1268
+ * through; a real's are then made its shortest decimal by
1269
+ * `fromCanonicalText` (0026).
1270
+ * - date `YYYY-MM-DD`; time `HH:MM`; an instant `YYYY-MM-DDTHH:MM:SSZ` in UTC.
1271
+ * Each is formancy's shape, and a time's seconds and an instant's fraction
1272
+ * are cut off, not rounded, because the shapes cannot hold them (0017).
1273
+ * - a zoneless timestamp: `YYYY-MM-DDTHH:MM:SS`, then its fraction with the
1274
+ * trailing zeros dropped, as PostgreSQL spells it (0026). See
1275
+ * `zonelessText`.
1276
+ * - uuid: lower case, as the codec spells it; SQL Server prints upper case.
1277
+ *
1278
+ * Text and decimals are read by the column's own type, never by the length,
1279
+ * precision or scale the snapshot remembers: CONVERT truncates text and
1280
+ * rounds a decimal to the type it is given without a word, so a column
1281
+ * widened since discovery would read back as a value it does not hold
1282
+ * (measured: 'Muster AG,' for 'Muster AG, Zurich branch', 1.2346 for
1283
+ * 1.234567). PostgreSQL reads `::text`, which loses nothing either.
1284
+ */
1285
+ function canonicalText(type, expression) {
1286
+ switch (type.kind) {
1287
+ case "text": return type.fixedLength ? `rtrim(convert(nvarchar(max), ${expression}))` : `convert(nvarchar(max), ${expression})`;
1288
+ case "integer": return `convert(nvarchar(20), ${expression})`;
1289
+ case "decimal": return `convert(nvarchar(50), ${expression}, 2)`;
1290
+ case "boolean": return `convert(nchar(1), ${expression})`;
1291
+ case "float": return `convert(nvarchar(30), ${expression}, 3)`;
1292
+ case "date": return `convert(nchar(10), ${expression}, 23)`;
1293
+ case "time": return `convert(nchar(5), ${expression}, 108)`;
1294
+ case "timestamp": return type.withTimeZone ? `convert(nchar(19), switchoffset(${expression}, '+00:00'), 126) + N'Z'` : zonelessText(expression);
1295
+ case "uuid": return `lower(convert(nchar(36), ${expression}))`;
1296
+ case "binary":
1297
+ case "rowversion":
1298
+ case "unsupported": throw noValue(type);
1299
+ }
1300
+ }
1301
+ /**
1302
+ * A zoneless timestamp as text, with its fraction and without its trailing
1303
+ * zeros: `2026-10-08T12:34:56.5`. Style 126 prints the column's own scale with
1304
+ * trailing zeros -- datetime2(3) holding .5 is `…56.500`, datetime2(7) holding
1305
+ * .12 is `…50.1200000`, datetime holding .007 is `…56.007` -- and leaves the
1306
+ * fraction out on a whole second. datetime keeps 1/300 s, so what it holds
1307
+ * for .007 is .00666…, and style 126 spells it to the millisecond: the one
1308
+ * read that is rounded, by SQL Server, and the contract says so. The zeros are trimmed by reversing the text
1309
+ * and keeping it up to the first digit that is not 0. Only text past nineteen
1310
+ * characters is trimmed -- it has a fraction, and the fraction a digit that
1311
+ * is not 0 -- so a whole second ending in 0 keeps it: `…12:34:50`, never
1312
+ * `…12:34:5`. Measured on SQL Server 2022 (16.0.4295) on 2026-10-09 for .12,
1313
+ * .5, .007, .01, .0000001, .9999999 and a whole second; the records suite
1314
+ * holds datetime2(7), datetime and smalldatetime to it. The conversion is
1315
+ * spelled five times, which the server evaluates per column read.
1316
+ */
1317
+ function zonelessText(expression) {
1318
+ const text = `convert(nvarchar(27), ${expression}, 126)`;
1319
+ return `case when len(${text}) > 19 then left(${text}, len(${text}) + 1 - patindex(N'%[^0]%', reverse(${text}))) else ${text} end`;
1320
+ }
1321
+ /**
1322
+ * The API value for text `canonicalText` produced: a boolean and a float become
1323
+ * JSON's own; everything else stays a string. A real becomes the shortest
1324
+ * decimal naming its float32 -- 0.1, never the 0.10000000149011612 its style 3
1325
+ * text is -- which is what the codec returns for it and what PostgreSQL reads
1326
+ * (0026), so a value read and sent back unchanged is unchanged.
1327
+ */
1328
+ function fromCanonicalText(type, text) {
1329
+ if (text === null) return null;
1330
+ if (type.kind === "boolean") return text === "1";
1331
+ if (type.kind === "float") return type.bits === 32 ? canonicalFloat32(Number(text)) : Number(text);
1332
+ return text;
1333
+ }
1334
+ function requireType(ok, type) {
1335
+ if (!ok) throw new Error(`A value for a ${type.kind} column is not the canonical value a codec returns`);
1336
+ }
1337
+ /** As text, for the server to convert: the one way into the database that loses nothing. */
1338
+ function asText(parameters, value) {
1339
+ return parameters.add(mssql.NVarChar(mssql.MAX), value);
1340
+ }
1341
+ /**
1342
+ * SQL that converts `text` — an nvarchar expression holding a canonical value
1343
+ * — to its column's type: `convert(decimal(18, 4), @p3)`. A timestamp is an
1344
+ * instant; a zoneless one is never written. One spelling for a value bound as
1345
+ * text and for a key read back as text, so a record is found again exactly
1346
+ * the way it is named.
1347
+ */
1348
+ function fromText(type, text) {
1349
+ switch (type.kind) {
1350
+ case "text": return text;
1351
+ case "integer": return `convert(${integerType(type)}, ${text})`;
1352
+ case "decimal": return `convert(${decimalType(type)}, ${text})`;
1353
+ case "date": return `convert(date, ${text}, 23)`;
1354
+ case "time": return `convert(time, ${text})`;
1355
+ case "timestamp": return `convert(datetimeoffset, ${text}, 127)`;
1356
+ case "uuid": return `convert(uniqueidentifier, ${text})`;
1357
+ }
1358
+ }
1359
+ /**
1360
+ * Whether a key of this kind read back by `canonicalText` is turned by
1361
+ * `fromText` into the very value it was read from. A time and an instant are
1362
+ * cut short (0017); a boolean and a float do not travel as text.
1363
+ */
1364
+ function isExactKey(type) {
1365
+ return type.kind === "text" || type.kind === "integer" || type.kind === "decimal" || type.kind === "uuid" || type.kind === "date";
1366
+ }
1367
+ /**
1368
+ * Binds one canonical value and returns the SQL expression that is it, typed
1369
+ * as its column: `convert(decimal(18, 4), @p3)`. Null is bound with the same
1370
+ * type, so it is a typed NULL.
1371
+ *
1372
+ * Text is bound as nvarchar(max), never with the column's length: a parameter
1373
+ * declared shorter than its value is truncated by the server without a word,
1374
+ * and the column's own length check is the one that should refuse.
1375
+ *
1376
+ * A value of the wrong JavaScript type is a programming error, refused before
1377
+ * anything is sent: the record port's values arrive as a codec returned them.
1378
+ */
1379
+ function bindValue(parameters, type, value) {
1380
+ const given = value === null ? null : typeof value;
1381
+ switch (type.kind) {
1382
+ case "integer":
1383
+ requireType(given === null || given === "string" || given === "number" && Number.isSafeInteger(value), type);
1384
+ return fromText(type, asText(parameters, value === null ? null : String(value)));
1385
+ case "boolean":
1386
+ requireType(given === null || given === "boolean", type);
1387
+ return parameters.add(mssql.Bit, value);
1388
+ case "float":
1389
+ requireType(given === null || given === "number" && Number.isFinite(value), type);
1390
+ return parameters.add(mssql.Float, value);
1391
+ case "timestamp":
1392
+ if (!type.withTimeZone) throw noValue(type);
1393
+ requireType(given === null || given === "string", type);
1394
+ return fromText(type, asText(parameters, value));
1395
+ case "text":
1396
+ case "decimal":
1397
+ case "date":
1398
+ case "time":
1399
+ case "uuid":
1400
+ requireType(given === null || given === "string", type);
1401
+ return fromText(type, asText(parameters, value));
1402
+ case "binary":
1403
+ case "rowversion":
1404
+ case "unsupported": throw noValue(type);
1405
+ }
1406
+ }
1407
+ //#endregion
1408
+ //#region src/sql/quote.ts
1409
+ /** sysname is nvarchar(128): no SQL Server identifier is longer, and QUOTENAME returns NULL past it. */
1410
+ const MAX_IDENTIFIER_LENGTH = 128;
1411
+ /**
1412
+ * One identifier, bracket-quoted with every `]` doubled, which is what
1413
+ * QUOTENAME does: inside brackets nothing else is special, so a name with a
1414
+ * dot, a space, a quote or a reserved word is one identifier and never SQL.
1415
+ *
1416
+ * Names come from approved metadata (CLAUDE.md, "Secrets and identifiers"),
1417
+ * so one that no catalog could hold — empty, longer than sysname, or carrying
1418
+ * a NUL — is a programming error, and refused before any SQL is built.
1419
+ */
1420
+ function quoteName(name) {
1421
+ if (typeof name !== "string" || name.length === 0 || name.length > MAX_IDENTIFIER_LENGTH || name.includes("\0")) throw new Error(`An identifier is 1 to ${String(MAX_IDENTIFIER_LENGTH)} characters without NUL, as SQL Server's catalog holds it`);
1422
+ return `[${name.replaceAll("]", "]]")}]`;
1423
+ }
1424
+ /**
1425
+ * A table or view, quoted one part at a time from its two fields. Never from
1426
+ * a dotted string: `sales.order` split at the dot is a guess, and a name that
1427
+ * contains a dot is legal once quoted (0015).
1428
+ */
1429
+ function quoteTable(ref) {
1430
+ return `${quoteName(ref.schema)}.${quoteName(ref.name)}`;
1431
+ }
1432
+ //#endregion
1433
+ //#region src/sql/order.ts
1434
+ /**
1435
+ * The order of `row`'s columns, made explicit about NULLs where a term says
1436
+ * where they go: SQL Server puts them first in an ascending order and has no
1437
+ * NULLS LAST, so such a column is preceded by a CASE that places them. Text
1438
+ * is ordered by the column's collation -- the server's rule, not code-point
1439
+ * order -- a uniqueidentifier by its byte groups (above), and the key that
1440
+ * ends every order makes it total, because a key is unique under that same
1441
+ * rule.
1442
+ *
1443
+ * The direction is spliced, because SQL Server takes no parameter for ASC or
1444
+ * DESC, so it is checked to be one of the two first: the core only writes
1445
+ * those, but this is handed a configuration or a request, and one read from
1446
+ * a bundle altered where it is stored must not run as a statement.
1447
+ */
1448
+ function orderBy(row, terms) {
1449
+ if (terms.length === 0) throw new Error("An order names at least one column.");
1450
+ return terms.flatMap((term) => {
1451
+ if (term.direction !== "asc" && term.direction !== "desc") throw new Error(`${term.column} sorts asc or desc`);
1452
+ const name = `${row}.${quoteName(term.column)}`;
1453
+ if (term.nulls === null) return [`${name} ${term.direction}`];
1454
+ if (term.nulls !== "first" && term.nulls !== "last") throw new Error(`${term.column} puts NULLs first or last`);
1455
+ const [nullRank, valueRank] = term.nulls === "first" ? ["0", "1"] : ["1", "0"];
1456
+ return [`case when ${name} is null then ${nullRank} else ${valueRank} end`, `${name} ${term.direction}`];
1457
+ }).join(", ");
1458
+ }
1459
+ //#endregion
1460
+ //#region src/sql/search.ts
1461
+ /**
1462
+ * The search as a LIKE pattern that matches only itself: `\` is the escape,
1463
+ * and the escape, `%`, `_` and `[` are escaped by it. `[` too, because
1464
+ * SQL Server reads `[a-c]` as a character class; `]`, `^` and `-` mean
1465
+ * something only inside one.
1466
+ */
1467
+ function containsPattern(search) {
1468
+ return `%${search.replace(/[\\%_[]/g, (character) => `\\${character}`)}%`;
1469
+ }
1470
+ /**
1471
+ * What a search column of `row` is matched as: text as itself, so the
1472
+ * column's collation folds case and accents as it does in an index; an
1473
+ * integer as its canonical text, the digits its label or its cell shows,
1474
+ * converted explicitly rather than by LIKE's implicit conversion (0028).
1475
+ */
1476
+ function searched(row, search) {
1477
+ const column = `${row}.${quoteName(search.name)}`;
1478
+ return search.type.kind === "integer" ? canonicalText(search.type, column) : column;
1479
+ }
1480
+ /** The condition that some column of `row` contains `search`, the pattern bound once. `columns` is never empty: a search over nothing is refused before SQL. */
1481
+ function searchPredicate(parameters, row, columns, search) {
1482
+ if (columns.length === 0) throw new Error("A search names at least one column; one over none would match nothing on one engine and fail on the other.");
1483
+ const pattern = parameters.add(mssql.NVarChar(mssql.MAX), containsPattern(search));
1484
+ return `(${columns.map((entry) => `${searched(row, entry)} like ${pattern} escape N'\\'`).join(" or ")})`;
1485
+ }
1486
+ //#endregion
1487
+ //#region src/sql/filters.ts
1488
+ /**
1489
+ * How a row filter and the write check compare text exactly (0028): the
1490
+ * column's canonical value is the trusted value, UTF-16 unit for unit, as
1491
+ * PostgreSQL compares two `"C"`-collated texts.
1492
+ *
1493
+ * SQL Server has no such operator. Every collation, BIN2 included, ignores
1494
+ * trailing spaces in `=`, so `N'acme' = N'acme ' collate BIN2` is true (C2),
1495
+ * and a filter of BIN2 alone let tenant `acme` read the rows of `acme `
1496
+ * (C2-table). Two equal-length strings that BIN2 calls equal are the same
1497
+ * string, so the byte lengths are compared too.
1498
+ */
1499
+ /** A collation that compares code point by code point: case and accents count, trailing spaces still do not. */
1500
+ const EXACT_COLLATION = "Latin1_General_100_BIN2";
1501
+ /**
1502
+ * SQL that is true when two nvarchar expressions are exactly the same text.
1503
+ * Both sides are canonical text — what `canonicalText` reads, or a bound
1504
+ * value — so a char(n)'s padding is already gone from both.
1505
+ */
1506
+ function exactText(left, right) {
1507
+ return `${left} = ${right} collate ${EXACT_COLLATION} and datalength(${left}) = datalength(${right})`;
1508
+ }
1509
+ /**
1510
+ * One row filter on `column`, binding its value once.
1511
+ *
1512
+ * A non-text value is bound as its column's type, as a record key is: the
1513
+ * canonical spelling `scopeRowFilters` checked converts to exactly one value.
1514
+ *
1515
+ * Text is compared three ways, all on one parameter:
1516
+ *
1517
+ * - `column = @p`, in the column's own collation, which admits a superset —
1518
+ * an exactly equal pair is equal under every collation — and is the
1519
+ * conjunct an index seeks on. Measured on SQL Server 2022 (16.0.4295) on
1520
+ * 2026-10-09 over 20,004 rows (C5): an nvarchar or nchar column seeks
1521
+ * (`GetRangeWithMismatchedTypes`), and so does a char or varchar under a
1522
+ * Windows collation; a char or varchar under a `SQL_` collation still
1523
+ * converts every row and scans, as it did before (0017). BIN2 alone never
1524
+ * seeked: the fixture's lookup scanned another index and looked each row up.
1525
+ * - the same under BIN2, so case and accents count.
1526
+ * - for variable-length text, the byte lengths, so trailing spaces count.
1527
+ * A fixed-length column needs none: its canonical value has no padding, a
1528
+ * value ending in a space is refused before this (`scopeRowFilters`), and
1529
+ * `=` ignores the padding the column stores.
1530
+ *
1531
+ * The parameter is nvarchar(max), never the column's length, which the
1532
+ * server would truncate it to without a word.
1533
+ */
1534
+ function filterPredicate(parameters, column, term) {
1535
+ const { type } = term;
1536
+ if (type.kind !== "text") return `${column} = ${bindValue(parameters, type, term.value)}`;
1537
+ const value = parameters.add(mssql.NVarChar(mssql.MAX), term.value);
1538
+ const exact = `${column} = ${value} and ${column} = ${value} collate ${EXACT_COLLATION}`;
1539
+ return type.fixedLength ? exact : `${exact} and datalength(convert(nvarchar(max), ${column})) = datalength(${value})`;
1540
+ }
1541
+ //#endregion
1542
+ //#region src/lookups/statements.ts
1543
+ /**
1544
+ * The parameters one statement may bind. SQL Server's message says "a maximum
1545
+ * of 2100 parameters", and a request with 2099 is refused with 8003: the
1546
+ * driver sends every parameterised statement through `sp_executesql`, whose
1547
+ * own `@stmt` and `@params` are two of the 2100. Measured against SQL Server
1548
+ * 2022 through `tedious` on 2026-10-09; the lookup suite holds the edge.
1549
+ */
1550
+ const PARAMETER_LIMIT = 2098;
1551
+ /** The table, under one alias every column is qualified with, so a column called `v` or `k0` cannot be mistaken for the key list's. */
1552
+ const TARGET = "[target]";
1553
+ function column$1(name) {
1554
+ return `${TARGET}.${quoteName(name)}`;
1555
+ }
1556
+ /** The key, each column as the canonical text a token is made of: `[k0]`, `[k1]`, … */
1557
+ function keySelect(config) {
1558
+ return config.targetColumns.map((key, index) => `${canonicalText(key.type, column$1(key.name))} as [k${String(index)}]`);
1559
+ }
1560
+ /**
1561
+ * The display columns, `[d0]`, `[d1]`, …, read as a record read reads them:
1562
+ * the canonical text of each, by the type the configuration carries, which
1563
+ * `fromCanonicalText` decodes and `displayText` spells (0028). Not SQL
1564
+ * Server's own style 126, which spelled a bit `1`, a uuid in upper case, a
1565
+ * float in scientific notation and an instant in its stored offset (C6).
1566
+ */
1567
+ function displaySelect(config) {
1568
+ return config.display.map((display, index) => `${canonicalText(display.type, column$1(display.name))} as [d${String(index)}]`);
1569
+ }
1570
+ /** A row whose key holds a NULL cannot be referenced by any foreign key value, so it is never offered. */
1571
+ function keysPresent(config) {
1572
+ return config.targetColumns.map((key) => `${column$1(key.name)} is not null`);
1573
+ }
1574
+ /**
1575
+ * The actor's trusted filters, in the same WHERE as everything else: a row
1576
+ * outside them does not exist for this call. Each compares exactly (0028).
1577
+ */
1578
+ function filtered(parameters, terms) {
1579
+ return terms.map((term) => filterPredicate(parameters, column$1(term.column), term));
1580
+ }
1581
+ /**
1582
+ * One page: up to `limit + 1` rows, so `lookupPage` can tell whether there is
1583
+ * more without a COUNT. The search is one parameter, matched against each
1584
+ * search column with LIKE.
1585
+ */
1586
+ function pageStatement(config, query, terms) {
1587
+ const parameters = new Parameters();
1588
+ const where = [...keysPresent(config), ...filtered(parameters, terms)];
1589
+ if (query.search !== "") {
1590
+ if (config.search.length === 0) throw new Error(`${config.source} has no searchable column`);
1591
+ where.push(searchPredicate(parameters, TARGET, config.search, query.search));
1592
+ }
1593
+ const offset = parameters.add(mssql.BigInt, query.offset);
1594
+ const fetch = parameters.add(mssql.Int, query.limit + 1);
1595
+ return parameters.statement(`select ${[...keySelect(config), ...displaySelect(config)].join(", ")} from ${quoteTable(config.target)} as ${TARGET}
1596
+ where ${where.join(" and ")}
1597
+ order by ${orderBy(TARGET, config.sort)}
1598
+ offset ${offset} rows fetch next ${fetch} rows only`);
1599
+ }
1600
+ /**
1601
+ * The rows, under the filters, whose key is one of `keys`. The keys travel as
1602
+ * a table value constructor joined by EXISTS, so a composite key is compared
1603
+ * column by column, each value converted to its key column's type, and a row
1604
+ * is returned at most once however many keys the database finds equal to it.
1605
+ */
1606
+ function keysStatement(config, keys, terms, withDisplay) {
1607
+ const parameters = new Parameters();
1608
+ const where = filtered(parameters, terms);
1609
+ const rows = keys.map((key) => `(${config.targetColumns.map((target, index) => bindValue(parameters, target.type, key[index] ?? null)).join(", ")})`);
1610
+ const names = config.targetColumns.map((_, index) => `[k${String(index)}]`);
1611
+ const matches = config.targetColumns.map((target, index) => `${column$1(target.name)} = [v].[k${String(index)}]`);
1612
+ where.push(`exists (select 1 from (values ${rows.join(", ")}) as [v] (${names.join(", ")}) where ${matches.join(" and ")})`);
1613
+ const select = withDisplay ? [...keySelect(config), ...displaySelect(config)] : keySelect(config);
1614
+ return parameters.statement(`select ${select.join(", ")} from ${quoteTable(config.target)} as ${TARGET} where ${where.join(" and ")}`);
1615
+ }
1616
+ /**
1617
+ * The keys in groups that each fit one statement: every key binds one
1618
+ * parameter per key column, and the filters bind theirs in every group.
1619
+ * Splitting makes resolve and rejects several reads rather than one, so a row
1620
+ * changed between two of them is seen as it was when its own group ran.
1621
+ */
1622
+ function keyGroups(config, keys, filterCount) {
1623
+ const size = Math.floor((PARAMETER_LIMIT - filterCount) / config.targetColumns.length);
1624
+ if (size < 1) throw new Error(`${config.source}: ${String(filterCount)} filters leave no parameter for a key`);
1625
+ const groups = [];
1626
+ for (let start = 0; start < keys.length; start += size) groups.push(keys.slice(start, start + size));
1627
+ return groups;
1628
+ }
1629
+ //#endregion
1630
+ //#region src/lookups/lookups.ts
1631
+ function foundRow(config, row) {
1632
+ return {
1633
+ key: config.targetColumns.map((_, index) => {
1634
+ const value = row[`k${String(index)}`];
1635
+ if (typeof value !== "string") throw new Error(`${config.source}: a key column came back without a value`);
1636
+ return value;
1637
+ }),
1638
+ display: config.display.map((display, index) => fromCanonicalText(display.type, row[`d${String(index)}`] ?? null))
1639
+ };
1640
+ }
1641
+ /**
1642
+ * The rows under the filters whose key is one of `keys`, read in as many
1643
+ * statements as the parameter limit needs, one after another so a long list
1644
+ * holds one connection rather than the pool. Each group runs until `signal`
1645
+ * aborts, and `runUntil` checks it before building the next, so an aborted
1646
+ * resolve sends no further group (0061).
1647
+ */
1648
+ async function findKeys(pool, config, keys, terms, withDisplay, signal) {
1649
+ const found = [];
1650
+ for (const group of keyGroups(config, keys, terms.length)) {
1651
+ const rows = await runUntil(pool, keysStatement(config, group, terms, withDisplay), signal);
1652
+ for (const row of rows) found.push(foundRow(config, row));
1653
+ }
1654
+ return found;
1655
+ }
1656
+ /**
1657
+ * The lookup half of the SQL Server adapter, through a pool the composition
1658
+ * root connected.
1659
+ *
1660
+ * Every answer is decided by `@formancy/data-core`'s helpers — which tokens
1661
+ * are asked about, how a page knows there is more, when a token is a member —
1662
+ * and this only queries. The filters are read with `rowFilterTerms` before
1663
+ * anything is built, so a request without a policy fails instead of seeing
1664
+ * every row, and they are part of the same statement as the rest.
1665
+ *
1666
+ * What SQL Server decides on its own, and the adapter does not hide: how a
1667
+ * search matches and how text orders is the column's collation, so on a
1668
+ * case-insensitive database a search for `muster` finds `Muster AG` and
1669
+ * `apple` sorts before `Banana`; and equality ignores trailing spaces, which
1670
+ * is why membership is the re-encoded row's and never the database's match
1671
+ * (0012). A filter is the exception: it compares the column's canonical value
1672
+ * with its trusted value exactly — case, accents and trailing spaces count —
1673
+ * as PostgreSQL's does, and still seeks an nvarchar column's index
1674
+ * (../sql/filters.ts, 0028). A label is the record reader's value, spelled by
1675
+ * `displayText`, so it reads the same on both engines.
1676
+ *
1677
+ * A database error propagates: a lookup that cannot answer refuses, and
1678
+ * formancy turns that into a refused submission.
1679
+ *
1680
+ * Every statement runs until the caller's signal aborts, and is then stopped
1681
+ * in the database by `request.cancel()`, a TDS attention (0061,
1682
+ * ../sql/statement.ts). A signal already aborted is refused before the
1683
+ * filters are read or a key is parsed, so a request already gone costs
1684
+ * nothing. The pool is the one records use: an attention travels on the
1685
+ * statement's own connection, so unlike PostgreSQL's cancel request it
1686
+ * cannot stop anyone else's statement. No per-request `requestTimeout` is
1687
+ * set; mssql's default of 15 s, which it hands tedious for every request
1688
+ * (lib/tedious/connection-pool.js), still bounds every statement.
1689
+ */
1690
+ function createSqlServerLookups(pool) {
1691
+ return {
1692
+ async search(config, query, filters, signal) {
1693
+ stopIfAborted(signal);
1694
+ const rows = await runUntil(pool, pageStatement(config, query, rowFilterTerms(filters)), signal);
1695
+ return lookupPage(config, rows.map((row) => foundRow(config, row)), query.limit);
1696
+ },
1697
+ async resolve(config, tokens, filters, signal) {
1698
+ stopIfAborted(signal);
1699
+ const terms = rowFilterTerms(filters);
1700
+ const keys = lookupKeys(config, tokens);
1701
+ if (keys.length === 0) return [];
1702
+ return resolvedRows(config, tokens, await findKeys(pool, config, keys, terms, true, signal));
1703
+ },
1704
+ async rejects(config, tokens, filters, signal) {
1705
+ stopIfAborted(signal);
1706
+ const terms = rowFilterTerms(filters);
1707
+ const keys = lookupKeys(config, tokens);
1708
+ const found = keys.length === 0 ? [] : await findKeys(pool, config, keys, terms, false, signal);
1709
+ return rejectedTokens(tokens, found.map((row) => row.key));
1710
+ }
1711
+ };
1712
+ }
1713
+ //#endregion
1714
+ //#region src/records/definition.ts
1715
+ /**
1716
+ * The facts of the object whose quoted name the parameter `table` holds, as
1717
+ * one JSON text; NULL when the catalog shows no such object as discovery
1718
+ * would describe it.
1719
+ */
1720
+ function factsOf(table) {
1721
+ const scoped = "(o.object_id)";
1722
+ return `(select ${OBJECT_KIND} as [kind], o.object_id as [object],
1723
+ (select ${COLUMN_DEFINITION} from ${COLUMN_SOURCE} where c.object_id = o.object_id order by c.column_id for json path, include_null_values) as [columns],
1724
+ (${KEYS_SQL(scoped)} for json path, include_null_values) as [keys],
1725
+ (${FOREIGN_KEYS_SQL(scoped)} for json path, include_null_values) as [foreignKeys]
1726
+ from sys.objects o
1727
+ where o.object_id = object_id(${table}) and ${DESCRIBED_OBJECT}
1728
+ for json path, without_array_wrapper, include_null_values)`;
1729
+ }
1730
+ /** The SHA-256 of a facts text, as varbinary(32); NULL for NULL. */
1731
+ function digestOf(facts) {
1732
+ return `hashbytes('SHA2_256', ${facts})`;
1733
+ }
1734
+ /** A definition this adapter made: a SHA-256 in hex. */
1735
+ const DEFINITION = /^[0-9a-f]{64}$/;
1736
+ /** The definition's bytes, to bind as varbinary(32); a definition this adapter did not make is a programming error. */
1737
+ function definitionBytes(token) {
1738
+ if (typeof token !== "string" || !DEFINITION.test(token)) throw new Error("A write carries the definition this adapter described the table with: a SHA-256 in hex.");
1739
+ return Buffer.from(token, "hex");
1740
+ }
1741
+ /**
1742
+ * The description, from the facts text and the digest the same statement
1743
+ * computed: `undefined` when there are no facts, `cut` when the text that
1744
+ * arrived is not the text that was hashed -- a TEXTSIZE shorter than it.
1745
+ */
1746
+ function describedFrom(facts, digest) {
1747
+ if (facts === null || digest === null) return void 0;
1748
+ if (!createHash("sha256").update(Buffer.from(facts, "utf16le")).digest().equals(digest)) return "cut";
1749
+ const parsed = JSON.parse(facts);
1750
+ const keys = keysOf(parsed.keys ?? []);
1751
+ return inSnapshotOrder({
1752
+ kind: parsed.kind,
1753
+ columns: (parsed.columns ?? []).map(describedColumn),
1754
+ primaryKey: keys.primaryKey,
1755
+ uniqueKeys: keys.uniqueKeys,
1756
+ foreignKeys: foreignKeysOf(parsed.foreignKeys ?? [], []),
1757
+ definition: digest.toString("hex")
1758
+ });
1759
+ }
1760
+ //#endregion
1761
+ //#region src/records/statements.ts
1762
+ /**
1763
+ * The SQL of each record operation. Every statement selects the same shape —
1764
+ * `[found]`, then `[c0]`, `[c1]`, … the canonical text of each requested
1765
+ * column, then `[version]` — so one function in records.ts reads all of them.
1766
+ *
1767
+ * The write batches raise errors of their own, below. A user error number is
1768
+ * anybody's — a customer's trigger may THROW 51701 too — so errors.ts takes
1769
+ * one as the adapter's only when its message is exactly the adapter's.
1770
+ */
1771
+ /** Raised when a text column did not store the text it was sent: a varchar whose code page lacks a character, or that drops trailing spaces. */
1772
+ const TEXT_NOT_STORED = 51701;
1773
+ /** Its message: the index of the written value, in the request's order. */
1774
+ const TEXT_NOT_STORED_MESSAGE = /^formancy: text not stored as sent: ([0-9]+)$/;
1775
+ /** Raised when an update matched more than one row: an identity that is not a key. */
1776
+ const NOT_ONE_ROW = 51702;
1777
+ const NOT_ONE_ROW_MESSAGE = "formancy: the identity matched more than one row";
1778
+ /** Raised when an enabled INSTEAD OF trigger decides what the write stores. An account that cannot see the table is refused before, by the definition's check (0041). */
1779
+ const DECIDED_BY_TRIGGER = 51703;
1780
+ const DECIDED_BY_TRIGGER_MESSAGE = "formancy: an INSTEAD OF trigger decides what this write stores";
1781
+ /** Raised when a trigger ended the write's transaction and began another. */
1782
+ const TRANSACTION_REPLACED = 51704;
1783
+ const TRANSACTION_REPLACED_MESSAGE = "formancy: a trigger ended the transaction of the write and began another";
1784
+ /** Raised from CATCH when the error came after a trigger had ended the write's transaction: it may have committed (0031). */
1785
+ const TRANSACTION_ENDED = 51705;
1786
+ const TRANSACTION_ENDED_MESSAGE = "formancy: a trigger ended the transaction of the write and then raised an error";
1787
+ /** Raised when the table's definition is not the one the write was decided over (0041), after the statement or in CATCH. */
1788
+ const DEFINITION_MOVED$1 = 51706;
1789
+ const DEFINITION_MOVED_MESSAGE = "formancy: table definition moved";
1790
+ /** Raised when a delete's account cannot see every key that refers to a table the delete reaches, or one acts on the delete that it was not decided over (0050, 0051). */
1791
+ const REACH_MOVED = 51707;
1792
+ const REACH_MOVED_MESSAGE = "formancy: what refers to the table moved";
1793
+ /** Raised when a delete's account may not DELETE from a table past the root the delete reaches (0051). */
1794
+ const REACH_UNDELETABLE = 51708;
1795
+ const REACH_UNDELETABLE_MESSAGE = "formancy: this account may not delete from a table the delete reaches";
1796
+ /**
1797
+ * What every statement returns, from `source` — `[r].` for the table, `inserted.`
1798
+ * for an OUTPUT clause. `[found]` is always there, so a request for no
1799
+ * columns is still a statement. A rowversion comes back as its 8 bytes, which
1800
+ * `encodeRowversion` spells, so the adapter and the core cannot spell a token
1801
+ * two ways; a version column as its decimal text.
1802
+ */
1803
+ function selection(columns, concurrency, source) {
1804
+ const selected = [{
1805
+ name: "[found]",
1806
+ declared: "int",
1807
+ expression: "1"
1808
+ }];
1809
+ columns.forEach((column, index) => {
1810
+ selected.push({
1811
+ name: `[c${String(index)}]`,
1812
+ declared: "nvarchar(max)",
1813
+ expression: canonicalText(column.type, `${source}${quoteName(column.name)}`)
1814
+ });
1815
+ });
1816
+ if (concurrency !== null) selected.push(versionOf(concurrency, source));
1817
+ return selected;
1818
+ }
1819
+ function versionOf(concurrency, source) {
1820
+ const column = `${source}${quoteName(concurrency.column)}`;
1821
+ return concurrency.kind === "rowversion" ? {
1822
+ name: "[version]",
1823
+ declared: "binary(8)",
1824
+ expression: column
1825
+ } : {
1826
+ name: "[version]",
1827
+ declared: "nvarchar(20)",
1828
+ expression: `convert(nvarchar(20), ${column})`
1829
+ };
1830
+ }
1831
+ function column(name) {
1832
+ return `[r].${quoteName(name)}`;
1833
+ }
1834
+ function keyPredicates(parameters, key) {
1835
+ return key.map((value) => `${column(value.name)} = ${bindValue(parameters, value.type, value.value)}`);
1836
+ }
1837
+ /** The trusted filters, each comparing the column's canonical value exactly (../sql/filters.ts, 0028). */
1838
+ function filterPredicates(parameters, terms) {
1839
+ return terms.map((term) => filterPredicate(parameters, column(term.column), term));
1840
+ }
1841
+ /** The alias of a through's target inside its EXISTS. */
1842
+ const PARENT = "[p]";
1843
+ /**
1844
+ * One through (0043) as a condition on the record: its parent row, the one
1845
+ * the record's foreign-key columns reference, is one the target's filter
1846
+ * admits. An EXISTS in the statement itself, under the statement's own
1847
+ * locks. The pair compares with `=`, as the foreign key does; the filter
1848
+ * terms compare exactly (0028). Every column is qualified, so a key onto the
1849
+ * same table compares the parent's key with the record's column and never a
1850
+ * row with itself. A record whose foreign key is NULL matches no parent.
1851
+ */
1852
+ function throughPredicate(parameters, through) {
1853
+ const pairs = through.pairs.map((pair) => `${PARENT}.${quoteName(pair.references)} = ${column(pair.column)}`);
1854
+ const terms = through.terms.map((term) => filterPredicate(parameters, `${PARENT}.${quoteName(term.column)}`, term));
1855
+ return `exists (select 1 from ${quoteTable(through.target)} as ${PARENT} where ${[...pairs, ...terms].join(" and ")})`;
1856
+ }
1857
+ /** What locates a record in every statement but an insert: its key, the trusted filters and every through. */
1858
+ function located(parameters, key, terms, through) {
1859
+ return [
1860
+ ...keyPredicates(parameters, key),
1861
+ ...filterPredicates(parameters, terms),
1862
+ ...through.map((entry) => throughPredicate(parameters, entry))
1863
+ ];
1864
+ }
1865
+ /** The table's quoted name, bound as `object_id` reads it. */
1866
+ function tableName(parameters, table) {
1867
+ return parameters.add(mssql.NVarChar(mssql.MAX), quoteTable(table));
1868
+ }
1869
+ /** The table's facts and their digest, as `[facts]` and `[digest]` of a derived table `[f]`. */
1870
+ function described(parameters, table) {
1871
+ return {
1872
+ columns: [{
1873
+ name: "[facts]",
1874
+ expression: "[f].[facts]"
1875
+ }, {
1876
+ name: "[digest]",
1877
+ expression: digestOf("[f].[facts]")
1878
+ }],
1879
+ from: `(select ${factsOf(tableName(parameters, table))} as [facts]) as [f]`
1880
+ };
1881
+ }
1882
+ /** A description's columns as a select list. */
1883
+ function describedSelect(columns) {
1884
+ return columns.map((column) => `${column.expression} as ${column.name}`).join(", ");
1885
+ }
1886
+ /** The table's description, in one statement: its facts and their digest. */
1887
+ function describeStatement(table) {
1888
+ const parameters = new Parameters();
1889
+ const { columns, from } = described(parameters, table);
1890
+ return parameters.statement(`select ${describedSelect(columns)} from ${from}`);
1891
+ }
1892
+ /**
1893
+ * One record under the filters, with the table as this same statement found
1894
+ * it (0041): one statement, which holds a schema-stability lock on the table
1895
+ * while it runs, so no ALTER lands between the facts and the record.
1896
+ * `top (2)`, so an identity that matched more than one row is noticed
1897
+ * without reading every row it matched; one row with a NULL `[found]` when
1898
+ * none is inside the filters.
1899
+ */
1900
+ function readStatement(request, terms, through) {
1901
+ const parameters = new Parameters();
1902
+ const { columns, from } = described(parameters, request.target.table);
1903
+ const where = located(parameters, request.key, terms, through);
1904
+ const record = selection(request.columns, request.target.concurrency, `[r].`).map((selected) => `${selected.expression} as ${selected.name}`);
1905
+ return parameters.statement(`select ${describedSelect(columns)}, [record].* from ${from} outer apply (select top (2) ${record.join(", ")} from ${quoteTable(request.target.table)} as [r] where ${where.join(" and ")}) as [record]`);
1906
+ }
1907
+ /**
1908
+ * Whether a record is there for this actor -- what tells a stale update from
1909
+ * one aimed at nothing -- and whether the table's definition is still the
1910
+ * one the update was decided over (0041), which comes first. Located as the
1911
+ * update is, inside the filters and every through (0043), so a record outside
1912
+ * them is not found whatever version was sent.
1913
+ */
1914
+ function existsStatement(request, terms, through, definition) {
1915
+ const parameters = new Parameters();
1916
+ const table = request.target.table;
1917
+ const same = `case when ${digestOf(factsOf(tableName(parameters, table)))} = ${parameters.add(mssql.VarBinary(32), definition)} then 1 else 0 end`;
1918
+ const where = located(parameters, request.key, terms, through);
1919
+ return parameters.statement(`select (select count(*) from ${quoteTable(table)} as [r] where ${where.join(" and ")}) as [found], ${same} as [same]`);
1920
+ }
1921
+ /**
1922
+ * Reads the version back from the row itself, after the statement and its
1923
+ * AFTER triggers and before the commit. OUTPUT saw the row before a trigger
1924
+ * that touches it — an audit column, a version the application's own trigger
1925
+ * moves — changed its version again; a token taken from OUTPUT would be stale
1926
+ * before anyone held it, and refuse the next save.
1927
+ *
1928
+ * The row is found by its identity as the statement wrote it, a generated one
1929
+ * included, captured as canonical text and converted back as a bound key is,
1930
+ * so it is found exactly as a later save names it. The text goes through
1931
+ * variables rather than a join on the table variable, whose column would
1932
+ * bring the database's collation into the comparison and conflict with a key
1933
+ * column's own (468). A target with no identity, or with a key column of
1934
+ * another kind — a time or an instant, whose text is cut short, a boolean or
1935
+ * a float, which do not travel as text — keeps the version the statement saw.
1936
+ */
1937
+ function versionAfterTriggers(target) {
1938
+ const keys = target.identity.map((column, index) => ({
1939
+ column: quoteName(column.name),
1940
+ type: column.type,
1941
+ variable: `@i${String(index)}`,
1942
+ name: `[i${String(index)}]`
1943
+ }));
1944
+ const exact = (key) => isExactKey(key.type);
1945
+ if (target.concurrency === null || keys.length === 0 || !keys.every(exact)) return {
1946
+ captured: [],
1947
+ declared: [],
1948
+ statements: []
1949
+ };
1950
+ const where = keys.map((key) => `[stored].${key.column} = ${fromText(key.type, key.variable)}`);
1951
+ const version = versionOf(target.concurrency, "[stored].");
1952
+ return {
1953
+ captured: keys.map((key) => ({
1954
+ name: key.name,
1955
+ declared: "nvarchar(max)",
1956
+ expression: canonicalText(key.type, `inserted.${key.column}`)
1957
+ })),
1958
+ declared: [`declare ${keys.map((key) => `${key.variable} nvarchar(max)`).join(", ")};`],
1959
+ statements: [`select ${keys.map((key) => `${key.variable} = ${key.name}`).join(", ")} from @written;`, `update [written] set [version] = ${version.expression} from @written as [written] cross join ${quoteTable(target.table)} as [stored] where ${where.join(" and ")};`]
1960
+ };
1961
+ }
1962
+ /**
1963
+ * Whether an INSTEAD OF trigger decides what this write stores. Such a
1964
+ * trigger runs in place of the statement, and OUTPUT returns the row as if
1965
+ * the statement had run — the identity 0, the values before the trigger
1966
+ * changed them, a row for an update that changed nothing — so nothing the
1967
+ * batch reads says what the trigger stored, and the write is refused.
1968
+ *
1969
+ * Asked of the catalog after the statement, so a table that is not there has
1970
+ * already failed it as schema-changed (208), and after the definition's
1971
+ * check, so a table the account writes but cannot see in the catalog --
1972
+ * denied VIEW DEFINITION, `object_id` NULL to it -- has already failed that
1973
+ * as a moved definition (51706): its facts are NULL. Whether a trigger
1974
+ * decides such a write cannot be told, and it is refused either way: fail
1975
+ * closed. Before 0041 this check refused it itself, by `object_id` being
1976
+ * NULL; that branch could no longer be reached and is gone. The table's
1977
+ * name is bound, as `object_id` reads it.
1978
+ */
1979
+ function insteadOfGuard(name, operation) {
1980
+ return `if exists (${`select 1 from sys.triggers as [t] join sys.trigger_events as [e] on [e].[object_id] = [t].[object_id] where [t].[parent_id] = object_id(${name}) and [t].[is_instead_of_trigger] = 1 and [t].[is_disabled] = 0 and [e].[type_desc] = N'${operation}'`}) throw ${String(DECIDED_BY_TRIGGER)}, N'${DECIDED_BY_TRIGGER_MESSAGE}', 1;`;
1981
+ }
1982
+ /**
1983
+ * Whether the table still has the definition the write was decided over
1984
+ * (0041), as a statement: NULL-safe, because `NULL <> x` is not true and a
1985
+ * table the catalog no longer shows hashes to NULL.
1986
+ */
1987
+ function definitionGuard(table, definition) {
1988
+ return [`set @d = ${digestOf(factsOf(table))};`, `if @d is null or @d <> ${definition} throw ${String(DEFINITION_MOVED$1)}, N'${DEFINITION_MOVED_MESSAGE}', 1;`];
1989
+ }
1990
+ /**
1991
+ * The batch every write runs in, so that what it stored is checked before it
1992
+ * commits, and nothing it did outlives an error:
1993
+ *
1994
+ * - `OUTPUT … INTO` a table variable, never a bare `OUTPUT`, which SQL Server
1995
+ * refuses (334) on a table with an enabled trigger. The output is the row as
1996
+ * the statement wrote it, before any AFTER trigger changed it; the version
1997
+ * is read back from the row afterwards (`versionAfterTriggers`).
1998
+ * - The transaction is the one the batch began. A trigger that rolls it back
1999
+ * and begins another leaves `@@trancount` as it was, so nothing is raised,
2000
+ * and the commit would commit an empty transaction over a write that is
2001
+ * gone; one that commits it and begins another has stored the write. The
2002
+ * batch cannot tell the two apart, and says so (errors.ts). CATCH asks the
2003
+ * same question: a trigger that committed and then raised an error of its
2004
+ * own has stored the write, and its error is not the refusal it looks
2005
+ * like. Measured (0031): in CATCH the transaction is no longer the one the
2006
+ * batch began after a trigger's COMMIT, its COMMIT and BEGIN, and its
2007
+ * ROLLBACK, and still is after a constraint's error or a trigger that only
2008
+ * raises. So the error is replaced by one that says the transaction ended
2009
+ * -- unknown-outcome, over-reported for the ROLLBACK -- unless it is 3609
2010
+ * or the batch's own replaced-transaction error, which already say so.
2011
+ * - No INSTEAD OF trigger decided what was stored (`insteadOfGuard`).
2012
+ * - Every text value is compared with what its column stored, exactly, and a
2013
+ * difference rolls the write back: SQL Server converts nvarchar to a
2014
+ * single-byte varchar without an error, a character its code page lacks
2015
+ * becoming its "best fit" or `?` (0017), and a varchar created under
2016
+ * ANSI_PADDING OFF drops trailing spaces without one (C12, 0028). The stored
2017
+ * side is canonical text, a char(n)'s padding trimmed, so one comparison
2018
+ * (`exactText`) serves both lengths. OUTPUT shows the row before an AFTER
2019
+ * trigger changed it, so this sees what the statement stored, not a trigger.
2020
+ * - TRY…CATCH rolls back on every error it catches, then rethrows it as it
2021
+ * was. xact_abort alone does not: a trigger's RAISERROR, unlike THROW, ends
2022
+ * nothing, so the statement and the commit would run and the driver would
2023
+ * still report the error — a refusal over a write that committed.
2024
+ * - `xact_abort`, for what CATCH cannot catch in its own scope: a table
2025
+ * missing when the batch compiles is resolved only when its statement
2026
+ * runs, after `begin transaction`, and without xact_abort its 208 leaves
2027
+ * the transaction open on the pooled connection. Both settings end with the
2028
+ * request, which runs inside `sp_executesql` (../sql/statement.ts).
2029
+ * - The table's definition is the one the write was decided over (0041),
2030
+ * read after the statement, while its locks keep any ALTER out: ALTER
2031
+ * needs a schema-modification lock, which waits for this transaction. And
2032
+ * again in CATCH, after the rollback: a statement that failed against a
2033
+ * moved table -- 'many' into a column retyped to int, 245 -- is answered
2034
+ * as the move. Only while the transaction is still the batch's own: one a
2035
+ * trigger ended is answered as ended, 3609, 51704 or 51705, before the
2036
+ * catalog is read, because the trigger may have committed the write
2037
+ * (0031). Measured: asked first, the definition answered "nothing was
2038
+ * written" over a row a trigger's COMMIT had stored. The catalog reads in
2039
+ * CATCH even when the transaction was doomed, once it is rolled back
2040
+ * (measured, the probes before 0041).
2041
+ */
2042
+ function writeBatch(parameters, { target, definition, operation, statement, assigned, returned, afterWrite }) {
2043
+ const guarded = assigned.flatMap(({ value, sql }, index) => value.type.kind === "text" && value.value !== null ? [{
2044
+ value,
2045
+ sql,
2046
+ index
2047
+ }] : []);
2048
+ const stored = guarded.map(({ value }, position) => ({
2049
+ name: `[w${String(position)}]`,
2050
+ declared: "nvarchar(max)",
2051
+ expression: canonicalText(value.type, `inserted.${quoteName(value.name)}`)
2052
+ }));
2053
+ const refind = operation === "DELETE" ? {
2054
+ captured: [],
2055
+ declared: [],
2056
+ statements: []
2057
+ } : versionAfterTriggers(target);
2058
+ const table = tableName(parameters, target.table);
2059
+ const moved = definitionGuard(table, parameters.add(mssql.VarBinary(32), definition));
2060
+ const captured = [
2061
+ ...returned,
2062
+ ...stored,
2063
+ ...refind.captured
2064
+ ];
2065
+ const output = `output ${captured.map((column) => column.expression).join(", ")} into @written (${captured.map((column) => column.name).join(", ")})`;
2066
+ const checks = guarded.map(({ sql, index }, position) => `if exists (select 1 from @written where not (${exactText(`[w${String(position)}]`, sql)})) throw ${String(TEXT_NOT_STORED)}, N'formancy: text not stored as sent: ${String(index)}', 1;`);
2067
+ return [
2068
+ "set nocount on;",
2069
+ "set xact_abort on;",
2070
+ `declare @written table (${captured.map((column) => `${column.name} ${column.declared}`).join(", ")});`,
2071
+ "declare @transaction bigint, @rows int, @ended bit, @d varbinary(32);",
2072
+ ...refind.declared,
2073
+ "begin try",
2074
+ "begin transaction;",
2075
+ "set @transaction = current_transaction_id();",
2076
+ `${statement(output)};`,
2077
+ "set @rows = @@rowcount;",
2078
+ `if coalesce(current_transaction_id(), 0) <> @transaction throw ${String(TRANSACTION_REPLACED)}, N'${TRANSACTION_REPLACED_MESSAGE}', 1;`,
2079
+ ...moved,
2080
+ insteadOfGuard(table, operation),
2081
+ ...afterWrite,
2082
+ ...checks,
2083
+ ...refind.statements,
2084
+ "commit transaction;",
2085
+ "end try",
2086
+ "begin catch",
2087
+ "set @ended = case when coalesce(current_transaction_id(), 0) <> @transaction then 1 else 0 end;",
2088
+ "if @@trancount > 0 rollback transaction;",
2089
+ `if @ended = 1 and error_number() <> 3609 and not (error_number() = ${String(TRANSACTION_REPLACED)} and error_message() = N'${TRANSACTION_REPLACED_MESSAGE}') throw ${String(TRANSACTION_ENDED)}, N'${TRANSACTION_ENDED_MESSAGE}', 1;`,
2090
+ "if @ended = 1 throw;",
2091
+ ...moved,
2092
+ "throw;",
2093
+ "end catch;",
2094
+ `select ${returned.map((column) => column.name).join(", ")} from @written;`
2095
+ ].join("\n");
2096
+ }
2097
+ /** Exactly the given columns, so a column with a default gets it and a generated one is never named. */
2098
+ function insertStatement(request, definition) {
2099
+ const parameters = new Parameters();
2100
+ const assigned = request.values.map((value) => ({
2101
+ value,
2102
+ sql: bindValue(parameters, value.type, value.value)
2103
+ }));
2104
+ const table = quoteTable(request.target.table);
2105
+ const columns = assigned.map(({ value }) => quoteName(value.name)).join(", ");
2106
+ const values = assigned.map(({ sql }) => sql).join(", ");
2107
+ const sql = writeBatch(parameters, {
2108
+ target: request.target,
2109
+ definition,
2110
+ operation: "INSERT",
2111
+ statement: (output) => assigned.length === 0 ? `insert into ${table} ${output} default values` : `insert into ${table} (${columns}) ${output} values (${values})`,
2112
+ assigned,
2113
+ returned: selection(request.returning, request.target.concurrency, "inserted."),
2114
+ afterWrite: []
2115
+ });
2116
+ return parameters.statement(sql);
2117
+ }
2118
+ /** Whether the row's version is still the one expected: a rowversion's 8 bytes, or a version column compared as a bigint. An update's and a delete's (0050). */
2119
+ function versionIs(parameters, concurrency, expected) {
2120
+ const version = column(concurrency.column);
2121
+ return expected.kind === "rowversion" ? `${version} = ${parameters.add(mssql.VarBinary(8), expected.bytes)}` : `${version} = convert(bigint, ${parameters.add(mssql.NVarChar(mssql.MAX), expected.value)})`;
2122
+ }
2123
+ /**
2124
+ * ONE guarded statement (0015): the key, the trusted filters, every through
2125
+ * (0043) and the expected version in a single WHERE, and for a version column
2126
+ * the increment in the same SET, so nothing can change between the check and
2127
+ * the write. A row the filters or a through exclude is not matched, so another
2128
+ * tenant's record cannot be changed even with its current version in hand.
2129
+ * More than one row matched means the identity is not a key, and the batch
2130
+ * rolls back rather than change several. The table is aliased, `update [r]
2131
+ * … from <table> as [r]`, so that the through's EXISTS can name the row.
2132
+ */
2133
+ function updateStatement(request, terms, through, expected, definition) {
2134
+ const parameters = new Parameters();
2135
+ const { target } = request;
2136
+ const assigned = request.set.map((value) => ({
2137
+ value,
2138
+ sql: bindValue(parameters, value.type, value.value)
2139
+ }));
2140
+ const set = assigned.map(({ value, sql }) => `${quoteName(value.name)} = ${sql}`);
2141
+ const version = column(target.concurrency.column);
2142
+ if (target.concurrency.kind === "version-column") set.push(`${quoteName(target.concurrency.column)} = ${version} + 1`);
2143
+ const where = [...located(parameters, request.key, terms, through), versionIs(parameters, target.concurrency, expected)];
2144
+ const sql = writeBatch(parameters, {
2145
+ target,
2146
+ definition,
2147
+ operation: "UPDATE",
2148
+ statement: (output) => `update [r] set ${set.join(", ")} ${output} from ${quoteTable(target.table)} as [r] where ${where.join(" and ")}`,
2149
+ assigned,
2150
+ returned: selection(request.returning, target.concurrency, "inserted."),
2151
+ afterWrite: [`if @rows > 1 throw ${String(NOT_ONE_ROW)}, N'${NOT_ONE_ROW_MESSAGE}', 1;`]
2152
+ });
2153
+ return parameters.statement(sql);
2154
+ }
2155
+ //#endregion
2156
+ //#region src/records/delete.ts
2157
+ /** sys.foreign_keys' `delete_referential_action` for each action a delete acts through. */
2158
+ const ACTION_CODES = {
2159
+ cascade: 1,
2160
+ "set-null": 2,
2161
+ "set-default": 3
2162
+ };
2163
+ /** A table's name as `object_id` and HAS_PERMS_BY_NAME read it: quoted, and bound. */
2164
+ const named$1 = (parameters, table) => tableName(parameters, table);
2165
+ /**
2166
+ * Whether the account still sees every key that refers to a table the delete
2167
+ * reaches, and every key that acts on it is one the reach accepts, as a
2168
+ * statement that raises 51707 when either fails (0050, 0051). The planner
2169
+ * refuses a grant over any other key by the snapshot (`deleteProblems`);
2170
+ * this is the same rule asked of the catalog inside the delete's
2171
+ * transaction, so a key added after the form was published is refused
2172
+ * there, and whatever its cascade did is rolled back with it.
2173
+ *
2174
+ * A key that acts on delete -- cascades, sets NULL or sets a default --
2175
+ * onto a table the delete removes rows of is accepted only as the reach
2176
+ * names it: the table it is on, its name, the table it refers to and its
2177
+ * action, the name compared under Latin1_General_100_BIN2, never under the
2178
+ * database's collation. A key that refers to a column the delete clears and
2179
+ * acts ON UPDATE would carry the change on, and is refused whatever it is.
2180
+ *
2181
+ * Run after the statement, under its locks: ADD FOREIGN KEY ... REFERENCES
2182
+ * a table needs a schema-modification lock, which waits for this
2183
+ * transaction. An edge constraint that cascades acts on a delete of a node
2184
+ * as such a key does, from either end of each of its clauses (0049, P2b),
2185
+ * and sys.foreign_keys does not list it, so sys.edge_constraints is read
2186
+ * too; none is ever accepted, since the snapshot names one only as a gap.
2187
+ *
2188
+ * SQL Server lists a key only to an account that may see the table the key
2189
+ * is on (0049, P2), so the catalog alone could miss one. The account sees
2190
+ * every key exactly under discovery's own conditions for a snapshot without
2191
+ * a `referenced-by` gap -- VIEW DEFINITION on the database, and no VIEW
2192
+ * DEFINITION or CONTROL deny on an object or a schema that binds it --
2193
+ * shared from `visibility.ts`, so the guard and the gap cannot disagree; an
2194
+ * account that lost either since its snapshot is refused, whether or not a
2195
+ * key is hidden from it. A disabled key is counted: enabled again, it acts
2196
+ * without anything here noticing. sys.foreign_keys is read by the
2197
+ * referenced object's id through an index seek (measured on 16.0.4295.3,
2198
+ * 2026-10-11, P8); the edge constraints' plan and cost are not measured.
2199
+ */
2200
+ function reachGuard(parameters, reach) {
2201
+ const removed = reach.removed.map((table) => `object_id(${named$1(parameters, table)})`).join(", ");
2202
+ const accepted = reach.accepted.length === 0 ? "" : ` and not exists (select 1 from (values ${reach.accepted.map((key) => `(${named$1(parameters, key.from)}, ${parameters.add(mssql.NVarChar(mssql.MAX), key.name)}, ${named$1(parameters, key.onto)}, ${parameters.add(mssql.TinyInt, ACTION_CODES[key.onDelete])})`).join(", ")}) as [a]([from], [key], [onto], [action])
2203
+ where [k].[parent_object_id] = object_id([a].[from]) and [k].[name] = [a].[key] collate Latin1_General_100_BIN2
2204
+ and [k].[referenced_object_id] = object_id([a].[onto]) and [k].[delete_referential_action] = [a].[action])`;
2205
+ const cleared = reach.cleared.flatMap(({ table, columns }) => columns.map((column) => `(${named$1(parameters, table)}, ${parameters.add(mssql.NVarChar(mssql.MAX), column)})`));
2206
+ const followed = cleared.length === 0 ? [] : [
2207
+ ` or exists (select 1 from sys.foreign_keys as [k] join sys.foreign_key_columns as [c] on [c].[constraint_object_id] = [k].[object_id]`,
2208
+ ` join (values ${cleared.join(", ")}) as [x]([table], [column]) on [k].[referenced_object_id] = object_id([x].[table])`,
2209
+ ` and col_name([c].[referenced_object_id], [c].[referenced_column_id]) = [x].[column] collate Latin1_General_100_BIN2`,
2210
+ ` where [k].[update_referential_action] <> 0)`
2211
+ ];
2212
+ return [
2213
+ `if coalesce(${VIEWS_DATABASE}, 0) <> 1`,
2214
+ ` or exists (select 1 from sys.database_permissions p where ${DENIED_OBJECTS})`,
2215
+ ` or exists (select 1 from sys.database_permissions p where ${DENIED_SCHEMAS})`,
2216
+ ` or exists (select 1 from sys.foreign_keys as [k] where [k].[referenced_object_id] in (${removed}) and [k].[delete_referential_action] <> 0${accepted})`,
2217
+ ` or exists (select 1 from sys.edge_constraints as [ec] join sys.edge_constraint_clauses as [cl] on [cl].[object_id] = [ec].[object_id]`,
2218
+ ` where ([cl].[from_object_id] in (${removed}) or [cl].[to_object_id] in (${removed})) and [ec].[delete_referential_action] <> 0)`,
2219
+ ...followed,
2220
+ ` throw ${String(REACH_MOVED)}, N'${REACH_MOVED_MESSAGE}', 1;`
2221
+ ].join("\n");
2222
+ }
2223
+ /**
2224
+ * Whether the account may DELETE from every table past the root the delete
2225
+ * reaches, as a statement that raises 51708 when it may not (0051). SQL
2226
+ * Server asks the deleting account nothing of the tables a cascade or a SET
2227
+ * NULL reaches -- no grant, and a DENY there does not stop it: measured on
2228
+ * 16.0.4295.3, 2026-10-12 (0051 P5) -- so the batch asks, and a delete is held
2229
+ * to what its account could have deleted directly, as PostgreSQL's lock holds
2230
+ * it. HAS_PERMS_BY_NAME applies grants, denies and their precedence; NULL,
2231
+ * for a name it cannot resolve, is a refusal. The root's own DELETE is the
2232
+ * statement's to refuse, 229, before anything here runs.
2233
+ */
2234
+ function reachDeletable(parameters, reach) {
2235
+ const past = [...reach.removed.slice(1), ...reach.cleared.map((entry) => entry.table)];
2236
+ if (past.length === 0) return [];
2237
+ return [`if exists (select 1 from (values ${past.map((table) => `(${named$1(parameters, table)})`).join(", ")}) as [t]([table]) where coalesce(has_perms_by_name([t].[table], N'OBJECT', N'DELETE'), 0) <> 1)`, ` throw ${String(REACH_UNDELETABLE)}, N'${REACH_UNDELETABLE_MESSAGE}', 1;`];
2238
+ }
2239
+ /**
2240
+ * ONE guarded delete (0050): the key, the trusted filters, every through and
2241
+ * the expected version in a single WHERE, in the write batch, so nothing can
2242
+ * change between the check and the delete and nothing it did outlives a
2243
+ * check that fails. OUTPUT ... INTO the batch's table variable returns the
2244
+ * key and the version the row was deleted at, the root's row alone -- not a
2245
+ * row a cascade deleted, measured (0051 P5); an AFTER DELETE trigger runs
2246
+ * before the checks, and what it does is the database's (0050). After the
2247
+ * statement, in order: the transaction is still the batch's, the definition
2248
+ * is the one the delete was decided over (0041), no INSTEAD OF DELETE
2249
+ * trigger decided it, the reach is what the delete was decided over and
2250
+ * visible (`reachGuard`), the account may delete from every table of it
2251
+ * (`reachDeletable`, 0051), and one row or none was deleted -- more is an
2252
+ * identity that is not a key, and the batch rolls back rather than delete
2253
+ * several.
2254
+ */
2255
+ function deleteStatement(request, terms, expected, definition) {
2256
+ const parameters = new Parameters();
2257
+ const { target } = request;
2258
+ const where = [...located(parameters, terms.key, terms.terms, terms.through), versionIs(parameters, target.concurrency, expected)];
2259
+ const sql = writeBatch(parameters, {
2260
+ target,
2261
+ definition,
2262
+ operation: "DELETE",
2263
+ statement: (output) => `delete [r] ${output} from ${quoteTable(terms.root)} as [r] where ${where.join(" and ")}`,
2264
+ assigned: [],
2265
+ returned: selection(target.identity, target.concurrency, "deleted."),
2266
+ afterWrite: [
2267
+ reachGuard(parameters, terms.reach),
2268
+ ...reachDeletable(parameters, terms.reach),
2269
+ `if @rows > 1 throw ${String(NOT_ONE_ROW)}, N'${NOT_ONE_ROW_MESSAGE}', 1;`
2270
+ ]
2271
+ });
2272
+ return parameters.statement(sql);
2273
+ }
2274
+ //#endregion
2275
+ //#region src/records/errors.ts
2276
+ const UNIQUE_KEY = /^Violation of (?:PRIMARY KEY|UNIQUE KEY) constraint '(.+?)'\. Cannot insert duplicate key in object '/;
2277
+ const UNIQUE_INDEX = /^Cannot insert duplicate key row in object '.+?' with unique index '(.+?)'\. The duplicate key value is /;
2278
+ /** A foreign key to its own table is FOREIGN KEY SAME TABLE for a reference to no row, SAME TABLE REFERENCE for a row still referenced. */
2279
+ const CONFLICT = /^The [A-Z]+ statement conflicted with the (FOREIGN KEY(?: SAME TABLE)?|(?:SAME TABLE )?REFERENCE|CHECK) constraint "(.+?)"\. The conflict occurred in /;
2280
+ /** Every translation of 547 keeps these SQL keywords: checked by hand in the 34 languages of SQL Server 2022 on 2026-10-09, and in German by the failures suite. */
2281
+ const FOREIGN_KEY_WORDS = /\b(?:FOREIGN KEY|REFERENCE)\b/;
2282
+ const NULL_COLUMN = /^Cannot insert the value NULL into column '(.+?)', table '/;
2283
+ const TRUNCATED_COLUMN = /^String or binary data would be truncated in table '.+?', column '(.+?)'\. Truncated value: /;
2284
+ const DENIED_COLUMN = /^The [A-Z ]+ permission was denied on the column '(.+?)' of the object '/;
2285
+ const INVALID_COLUMN = /^Invalid column name '(.+)'\.$/;
2286
+ /** Conversions and overflows: a value the codec accepted and the column, or the column's type, could not hold. */
2287
+ const OUT_OF_RANGE = /* @__PURE__ */ new Set([
2288
+ 220,
2289
+ 232,
2290
+ 244,
2291
+ 245,
2292
+ 248,
2293
+ 8114,
2294
+ 8115
2295
+ ]);
2296
+ /**
2297
+ * A column the database writes itself, which a binding named for a write: an
2298
+ * identity (544 on insert, 8102 on update), a computed column (271), a
2299
+ * rowversion (273 on insert, 272 on update — measured, not 273 for both), a
2300
+ * GENERATED ALWAYS column such as a system-versioning period's start or end
2301
+ * (13536 on insert, 13537 on update). A
2302
+ * binding comes to name one when the column became one after discovery, so
2303
+ * it is `schema-changed`, as PostgreSQL's 428C9 is (C8, 0028). The parity
2304
+ * suite provokes every number here.
2305
+ */
2306
+ const GENERATED_COLUMN = /* @__PURE__ */ new Set([
2307
+ 544,
2308
+ 8102,
2309
+ 271,
2310
+ 273,
2311
+ 272,
2312
+ 13536,
2313
+ 13537
2314
+ ]);
2315
+ /**
2316
+ * The refusals SQL Server documents as passing, and the only `unavailable`
2317
+ * ones. 1205, a deadlock victim, and 3906, a database switched to read-only,
2318
+ * are provoked by the parity suite; the others are by documentation, not
2319
+ * provoked.
2320
+ */
2321
+ const PASSING = /* @__PURE__ */ new Set([
2322
+ 1205,
2323
+ 1222,
2324
+ 1204,
2325
+ 701,
2326
+ 8645,
2327
+ 8651,
2328
+ 9002,
2329
+ 1105,
2330
+ 3960,
2331
+ 3906,
2332
+ 976,
2333
+ 983
2334
+ ]);
2335
+ /** The first number SQL Server leaves to users: THROW's and RAISERROR's own. */
2336
+ const FIRST_USER_ERROR = 5e4;
2337
+ function named(pattern, message) {
2338
+ return pattern.exec(message)?.[1];
2339
+ }
2340
+ function failure(code, message, names = {}) {
2341
+ return {
2342
+ ok: false,
2343
+ code,
2344
+ ...names.column === void 0 ? {} : { column: names.column },
2345
+ ...names.constraint === void 0 ? {} : { constraint: names.constraint },
2346
+ message
2347
+ };
2348
+ }
2349
+ /**
2350
+ * Error 547 is a foreign key and a check alike; which one is the constraint
2351
+ * the message names. In English the message says so in a fixed sentence; in
2352
+ * any other language the kind is the SQL keyword the translation keeps, and
2353
+ * the name is left out because its quoting differs per language.
2354
+ */
2355
+ function constraintConflict(message) {
2356
+ const match = CONFLICT.exec(message);
2357
+ const kind = match?.[1];
2358
+ return (kind === void 0 ? FOREIGN_KEY_WORDS.test(message) : kind !== "CHECK") ? failure("foreign-key-violation", "SQL Server refused the write: a foreign key has no row to reference, or a referenced row is still referenced (547).", { constraint: match?.[2] }) : failure("check-violation", "SQL Server refused the write: a check constraint does not accept a value (547).", { constraint: match?.[2] });
2359
+ }
2360
+ /** A trigger ended the transaction the write ran in. Whether by COMMIT or by ROLLBACK, nothing the batch can read says. */
2361
+ const ENDED_BY_TRIGGER = "it may have committed, and it is not retried.";
2362
+ /**
2363
+ * The write batch's own errors (statements.ts), recognised by number AND
2364
+ * message: a customer's trigger may THROW the same number, and its error is
2365
+ * then an unrecognised refusal like any other.
2366
+ *
2367
+ * - An INSTEAD OF trigger that decides the write is `refused`: the batch
2368
+ * rolled it back, and the same write would be refused again (0028). Not
2369
+ * `schema-changed`, which would claim a binding names something gone, and
2370
+ * send someone to a drift review that cannot show a trigger (0017).
2371
+ * - A transaction a trigger replaced is `unknown-outcome`: it may have
2372
+ * committed the write before beginning another. So is one a trigger ended
2373
+ * and then raised an error in: the error is not a refusal of a write the
2374
+ * COMMIT stored (0031).
2375
+ * - A table whose definition moved is `schema-changed` (0041): the batch
2376
+ * found, after its statement or in CATCH, that the table is not the one
2377
+ * the write was decided over, and rolled it back. So is a delete whose
2378
+ * reach moved, or whose account no longer sees every key (0050).
2379
+ */
2380
+ function ownError(error, written) {
2381
+ if (error.number === 51702 && error.message === "formancy: the identity matched more than one row") throw new Error("The record identity matched more than one row, so the update was rolled back: the bindings do not name a key.");
2382
+ if (error.number === 51703 && error.message === "formancy: an INSTEAD OF trigger decides what this write stores") return failure("refused", "An INSTEAD OF trigger would decide what this write stores, so the adapter refused it; nothing was written.");
2383
+ if (error.number === 51704 && error.message === "formancy: a trigger ended the transaction of the write and began another") return failure("unknown-outcome", `A trigger ended the transaction of the write and began another; ${ENDED_BY_TRIGGER}`);
2384
+ if (error.number === 51705 && error.message === "formancy: a trigger ended the transaction of the write and then raised an error") return failure("unknown-outcome", `A trigger ended the transaction of the write and then raised an error; ${ENDED_BY_TRIGGER}`);
2385
+ if (error.number === 51706 && error.message === "formancy: table definition moved") return failure("schema-changed", "The table's definition is not the one this write was decided over; nothing was written.");
2386
+ if (error.number === 51707 && error.message === "formancy: what refers to the table moved") return failure("schema-changed", "A foreign key now acts on this delete that it was not decided over, or this account can no longer see every key that refers to a table it reaches; nothing was deleted.");
2387
+ if (error.number === 51708 && error.message === "formancy: this account may not delete from a table the delete reaches") return failure("permission-denied", "This account may not DELETE from a table this delete reaches, so the delete was refused and rolled back; nothing was deleted.");
2388
+ const index = error.number === 51701 ? TEXT_NOT_STORED_MESSAGE.exec(error.message)?.[1] : void 0;
2389
+ if (index === void 0) return void 0;
2390
+ return failure("out-of-range", "SQL Server did not store this text as sent: its code page lacks a character, or the column drops trailing spaces, and the write was rolled back.", { column: written[Number(index)]?.name });
2391
+ }
2392
+ /**
2393
+ * A refusal the server reported for this statement, which therefore did not
2394
+ * commit: the write batch rolls back on every error it is told of, a
2395
+ * trigger's RAISERROR included, and on the errors it cannot catch xact_abort
2396
+ * does (statements.ts). Except 3609: a trigger ended the write's transaction
2397
+ * itself, and SQL Server raises it alike after a COMMIT, which stored the
2398
+ * write, and after a ROLLBACK, which did not. A trigger that commits and then
2399
+ * raises an error of its own never reaches here as that error: the batch's
2400
+ * CATCH sees its transaction gone and raises its own instead (statements.ts,
2401
+ * 0031).
2402
+ */
2403
+ function refusal(error, written) {
2404
+ const own = ownError(error, written);
2405
+ if (own !== void 0) return own;
2406
+ const { number, message } = error;
2407
+ switch (number) {
2408
+ case 3609: return failure("unknown-outcome", `A trigger ended the transaction of the write (3609); ${ENDED_BY_TRIGGER}`);
2409
+ case 2627:
2410
+ case 2601: return failure("unique-violation", `SQL Server refused the write: a unique key already holds this value (${String(number)}).`, { constraint: named(number === 2627 ? UNIQUE_KEY : UNIQUE_INDEX, message) });
2411
+ case 547: return constraintConflict(message);
2412
+ case 515: return failure("not-null-violation", "SQL Server refused the write: a column that does not allow NULL was given none (515).", { column: named(NULL_COLUMN, message) });
2413
+ case 2628: return failure("too-long", "SQL Server refused the write: a value is longer than its column (2628).", { column: named(TRUNCATED_COLUMN, message) });
2414
+ case 8152: return failure("too-long", "SQL Server refused the write: a value is longer than its column (8152).");
2415
+ case 229:
2416
+ case 230: return failure("permission-denied", `SQL Server refused this account the operation (${String(number)}).`, { column: named(DENIED_COLUMN, message) });
2417
+ case 33504: return failure("permission-denied", "A security policy on this table refused the row this write would store (33504).");
2418
+ case 207:
2419
+ case 208: return failure("schema-changed", `A table or column the binding names is not there any more (${String(number)}).`, { column: named(INVALID_COLUMN, message) });
2420
+ default:
2421
+ if (OUT_OF_RANGE.has(number)) return failure("out-of-range", `SQL Server could not hold a value in its column's type (${String(number)}).`);
2422
+ if (GENERATED_COLUMN.has(number)) return failure("schema-changed", `A column the binding writes is one SQL Server now generates itself (${String(number)}); nothing was written.`);
2423
+ if (PASSING.has(number)) return failure("unavailable", `SQL Server could not complete this now (${String(number)}), and nothing was written.`);
2424
+ if (number >= FIRST_USER_ERROR) return failure("refused", `The database refused the statement with an error of its own (${String(number)}), a trigger's or a procedure's, and nothing was written.`);
2425
+ return failure("refused", `SQL Server refused the statement with error ${String(number)}, and nothing was written.`);
2426
+ }
2427
+ }
2428
+ /**
2429
+ * A refusal SQL Server reported for the statement, with the connection still
2430
+ * alive — severity 20 and above end the session.
2431
+ *
2432
+ * `typeof number === 'number'` is the test, not `number !== undefined`: when
2433
+ * the driver's error has no `info` — a socket that closed, a request that
2434
+ * timed out — mssql's lib/error/request-error.js copies its `code` into
2435
+ * `number`, so a lost answer is a `RequestError` whose number is the string
2436
+ * `'ECONNRESET'` or `'ETIMEOUT'`. Read as a number, it would be a refusal,
2437
+ * `refused`, a claim that nothing was written over a write that may have
2438
+ * committed. records-lost-answer.integration.test.ts pins both (0031).
2439
+ */
2440
+ function serverError(error) {
2441
+ const { number, class: severity } = error;
2442
+ if (error.name !== "RequestError" || typeof number !== "number") return void 0;
2443
+ if (typeof severity === "number" && severity >= 20) return void 0;
2444
+ return {
2445
+ number,
2446
+ message: error.message
2447
+ };
2448
+ }
2449
+ /**
2450
+ * The failure for what the driver rejected one statement with.
2451
+ *
2452
+ * - Anything but a `RequestError` is the pool's, from before it handed out a
2453
+ * connection: `mssql` turns every failure after that into a `RequestError`
2454
+ * (lib/tedious/request.js), and before it passes on what the pool rejected
2455
+ * with — its own `ConnectionError` for a pool that is closed or cannot
2456
+ * connect, and tarn's `TimeoutError`, unwrapped, when no connection came
2457
+ * free within `acquireTimeoutMillis`. Nothing was sent, so even a write is
2458
+ * `unavailable`.
2459
+ * - The server refused the statement: the code for its error number.
2460
+ * - Any other `RequestError` — a timeout, a socket that closed, a session
2461
+ * killed (596, severity 21) — after a write was sent is `unknown-outcome`: it
2462
+ * may have committed, and it is never retried (plan section 12). After a
2463
+ * read it is `unavailable`, because a read changed nothing.
2464
+ *
2465
+ * A request the driver would not take is thrown before it is sent, and never
2466
+ * reaches this. `written` is the values the statement assigned, in its order,
2467
+ * so a text value that was not stored can be named.
2468
+ */
2469
+ function failureFor(error, phase, written = []) {
2470
+ if (!(error instanceof Error)) throw error;
2471
+ if (error.name !== "RequestError") return failure("unavailable", "SQL Server could not be reached, or no connection came free in time, and nothing was sent.");
2472
+ const server = serverError(error);
2473
+ if (server !== void 0) return refusal(server, written);
2474
+ return phase === "write" ? failure("unknown-outcome", "The connection failed after the write was sent; it may have committed. It is not retried.") : failure("unavailable", "The connection failed during the read, which changed nothing.");
2475
+ }
2476
+ //#endregion
2477
+ //#region src/records/list.ts
2478
+ /**
2479
+ * One page of a form's records on SQL Server (0044), with the table as this
2480
+ * same statement found it (0041), in one statement -- which holds a
2481
+ * schema-stability lock on the table while it runs, as a read does, so no
2482
+ * ALTER lands between the facts and the page:
2483
+ *
2484
+ * select <description, on the page's first row>, [page].*
2485
+ * from (select <the table's facts> as [facts]) as [f]
2486
+ * outer apply (
2487
+ * select 1 as [found], [c0], …, [version], row_number() over (order by <order>) as [n]
2488
+ * from <table> as [r] where <parent> and <filters> and <throughs> and <search>
2489
+ * order by <order> offset @o rows fetch next @l rows only
2490
+ * ) as [page]
2491
+ * order by [page].[n]
2492
+ *
2493
+ * One row with a NULL `[found]` when the page is empty, so the description is
2494
+ * answered either way, as a read's is. The rows of an APPLY have no order of
2495
+ * their own, so each carries its position in the page's order -- which is
2496
+ * total, the key ending it, so the position is the order -- and the
2497
+ * statement is ordered by it. The description is selected once, on the
2498
+ * page's first row, whose position is `offset + 1`: repeated, it would send
2499
+ * the table's catalog facts once per record.
2500
+ *
2501
+ * The parent's columns compare with `=`, as the foreign key does, each value
2502
+ * bound as its column's type, as a key is; the filters, the throughs and the
2503
+ * search are a read's and a lookup's own spellings (`statements.ts`,
2504
+ * `sql/search.ts`). A nullable column's NULLs are placed by a CASE before it
2505
+ * in the order, and a NOT NULL column is ordered bare, so an index on it can
2506
+ * serve the order (`sql/order.ts`). `limit + 1` rows, so `recordPage` can
2507
+ * tell whether more follow without a COUNT.
2508
+ */
2509
+ function listStatement(request, terms) {
2510
+ const parameters = new Parameters();
2511
+ const { columns, from } = described(parameters, request.target.table);
2512
+ const where = [
2513
+ ...keyPredicates(parameters, terms.equal),
2514
+ ...filterPredicates(parameters, terms.terms),
2515
+ ...terms.through.map((entry) => throughPredicate(parameters, entry))
2516
+ ];
2517
+ if (terms.search !== null) where.push(searchPredicate(parameters, "[r]", terms.search.columns, terms.search.text));
2518
+ const order = orderBy("[r]", terms.sort);
2519
+ const offset = parameters.add(mssql.BigInt, terms.offset);
2520
+ const fetch = parameters.add(mssql.Int, terms.limit + 1);
2521
+ const first = parameters.add(mssql.BigInt, terms.offset + 1);
2522
+ const page = [
2523
+ `select ${[...selection(request.columns, request.target.concurrency, `[r].`).map((selected) => `${selected.expression} as ${selected.name}`), `row_number() over (order by ${order}) as [n]`].join(", ")} from ${quoteTable(request.target.table)} as [r]`,
2524
+ where.length > 0 ? `where ${where.join(" and ")}` : "",
2525
+ `order by ${order} offset ${offset} rows fetch next ${fetch} rows only`
2526
+ ].filter((part) => part !== "").join(" ");
2527
+ const once = columns.map((column) => `case when [page].[n] is null or [page].[n] = ${first} then ${column.expression} end as ${column.name}`);
2528
+ return parameters.statement(`select ${once.join(", ")}, [page].* from ${from} outer apply (${page}) as [page] order by [page].[n]`);
2529
+ }
2530
+ //#endregion
2531
+ //#region src/records/records.ts
2532
+ const NOT_FOUND = {
2533
+ ok: false,
2534
+ code: "not-found",
2535
+ message: "No such record is visible to this request."
2536
+ };
2537
+ const STALE = {
2538
+ ok: false,
2539
+ code: "stale",
2540
+ message: "The record has changed since it was read."
2541
+ };
2542
+ /** The table moved under an update that matched nothing, or that was never sent (0041). */
2543
+ const DEFINITION_MOVED = {
2544
+ ok: false,
2545
+ code: "schema-changed",
2546
+ message: "The table's definition is not the one this write was decided over; nothing was written."
2547
+ };
2548
+ /** An object the catalog does not show this account as discovery describes one: gone, out of sight, or not a table or a view. Which, drift review says (0010). */
2549
+ const NOT_DESCRIBED = {
2550
+ ok: false,
2551
+ code: "schema-changed",
2552
+ message: "The catalog does not show this table as discovery describes one."
2553
+ };
2554
+ /** The facts arrived shorter than they were hashed: a TEXTSIZE the connection set. Nothing can be decided over them. */
2555
+ const CUT = {
2556
+ ok: false,
2557
+ code: "unavailable",
2558
+ message: "The table's description arrived cut short, so nothing can be decided over it: the connection's TEXTSIZE is smaller than the description."
2559
+ };
2560
+ /** The largest bigint: a version column's token is compared as one. */
2561
+ const BIGINT_MAX = 9223372036854775807n;
2562
+ const CANONICAL_INTEGER = /^-?(?:0|[1-9][0-9]*)$/;
2563
+ function names(values) {
2564
+ return values.map((value) => value.name);
2565
+ }
2566
+ /**
2567
+ * The key names exactly the target's identity, each column once. Anything
2568
+ * else could match several records — or, with no identity at all, every
2569
+ * record — so it is a programming error, refused before anything is sent.
2570
+ */
2571
+ function checkKey(target, key) {
2572
+ const identity = names(target.identity);
2573
+ const given = names(key);
2574
+ if (identity.length === 0 || given.length !== identity.length || new Set(given).size !== given.length || !identity.every((name) => given.includes(name))) throw new Error(`A record key names exactly its identity (${identity.join(", ")}), and this one names (${given.join(", ")})`);
2575
+ }
2576
+ /** The values a write assigns: each column once, which SQL Server would otherwise refuse with an error that is not a refusal. */
2577
+ function checkAssigned(values, reserved) {
2578
+ const given = names(values);
2579
+ if (new Set(given).size !== given.length) throw new Error("A write names each column once");
2580
+ if (reserved !== null && given.includes(reserved)) throw new Error(`${reserved} is the concurrency column; the database or the adapter writes it`);
2581
+ }
2582
+ /**
2583
+ * The version as it is compared, or `undefined` for a token no record can
2584
+ * hold — not 16 lower-case hex digits for a rowversion, not a canonical
2585
+ * integer within bigint for a version column. Such a token is never bound; it
2586
+ * gets the answer a token that does not match gets.
2587
+ */
2588
+ function expectedVersion(concurrency, token) {
2589
+ if (concurrency.kind === "rowversion") {
2590
+ const bytes = decodeRowversion(token);
2591
+ return bytes === void 0 ? void 0 : {
2592
+ kind: "rowversion",
2593
+ bytes: Buffer.from(bytes)
2594
+ };
2595
+ }
2596
+ if (typeof token !== "string" || !CANONICAL_INTEGER.test(token) || token === "-0") return void 0;
2597
+ const value = BigInt(token);
2598
+ return value > BIGINT_MAX || value < -9223372036854775807n - 1n ? void 0 : {
2599
+ kind: "version-column",
2600
+ value: token
2601
+ };
2602
+ }
2603
+ /** The token for the version a row came back with: `encodeRowversion`'s hex, or a version column's decimal string. */
2604
+ function versionToken(concurrency, row) {
2605
+ if (concurrency === null) return null;
2606
+ const version = row.version;
2607
+ if (concurrency.kind === "rowversion") {
2608
+ if (!(version instanceof Uint8Array)) throw new Error(`${concurrency.column} did not come back as a rowversion`);
2609
+ return encodeRowversion(version);
2610
+ }
2611
+ if (typeof version !== "string") throw new Error(`${concurrency.column} did not come back as a version`);
2612
+ return version;
2613
+ }
2614
+ function recordFrom(row, columns, concurrency) {
2615
+ return {
2616
+ values: Object.fromEntries(columns.map((column, index) => {
2617
+ const text = row[`c${String(index)}`];
2618
+ return [column.name, fromCanonicalText(column.type, typeof text === "string" ? text : null)];
2619
+ })),
2620
+ version: versionToken(concurrency, row)
2621
+ };
2622
+ }
2623
+ /** Runs one statement, and turns what the driver rejected it with into a failure. A request it would not even take is thrown. */
2624
+ async function attempt(pool, statement, phase, written = []) {
2625
+ const request = prepare(pool, statement);
2626
+ try {
2627
+ return await send(request, statement);
2628
+ } catch (error) {
2629
+ return failureFor(error, phase, written);
2630
+ }
2631
+ }
2632
+ function onlyRow(rows) {
2633
+ if (rows.length > 1) throw new Error("The record identity matched more than one row: the bindings do not name a key.");
2634
+ return rows[0];
2635
+ }
2636
+ /**
2637
+ * The record half of the SQL Server adapter, through a pool the composition
2638
+ * root connected (0015).
2639
+ *
2640
+ * Values arrive canonical and are bound as text the server converts; they
2641
+ * leave as text the server produced (`../sql/values.ts`). Each write is one
2642
+ * batch: the statement, a check that its transaction is still the one it
2643
+ * began and that no INSTEAD OF trigger decided what it stored, a check that
2644
+ * every text value was stored as sent, the version read back from the row
2645
+ * once its triggers ran, and a commit, all rolled back on any error — so a
2646
+ * single-byte varchar cannot quietly keep `LA` for `ŁA`, neither an INSTEAD
2647
+ * OF trigger nor one that swaps the transaction can make a write that was not
2648
+ * stored look done, and a refusal is not reported over a write the batch
2649
+ * committed. A trigger that ends the transaction itself is `unknown-outcome`,
2650
+ * whether or not it then raises an error, because whether it committed cannot
2651
+ * be told. A database error is a
2652
+ * `RecordFailure`, never thrown; a malformed request — a key that is not the
2653
+ * identity, a column named twice, a value no codec returns — is a programming
2654
+ * error and is.
2655
+ */
2656
+ /** The description a row of `describeStatement` or `readStatement` carries, or why there is none. */
2657
+ function describedBy(row) {
2658
+ const facts = row.facts;
2659
+ const digest = row.digest;
2660
+ return describedFrom(typeof facts === "string" ? facts : null, digest instanceof Uint8Array ? Buffer.from(digest) : null);
2661
+ }
2662
+ function createSqlServerRecords(pool) {
2663
+ return {
2664
+ async describe(table) {
2665
+ const rows = await attempt(pool, describeStatement(table), "read");
2666
+ if (!Array.isArray(rows)) return rows;
2667
+ const described = describedBy(rows[0]);
2668
+ if (described === "cut") return CUT;
2669
+ return described === void 0 ? NOT_DESCRIBED : {
2670
+ ok: true,
2671
+ described
2672
+ };
2673
+ },
2674
+ async read(request) {
2675
+ const terms = rowFilterTerms(request.filters);
2676
+ const through = throughTerms(request.through);
2677
+ checkKey(request.target, request.key);
2678
+ const rows = await attempt(pool, readStatement(request, terms, through), "read");
2679
+ if (!Array.isArray(rows)) return rows;
2680
+ const row = onlyRow(rows);
2681
+ const described = describedBy(row);
2682
+ if (described === "cut") return CUT;
2683
+ if (described === void 0) return NOT_DESCRIBED;
2684
+ return {
2685
+ ok: true,
2686
+ described,
2687
+ record: row.found === null ? null : recordFrom(row, request.columns, request.target.concurrency)
2688
+ };
2689
+ },
2690
+ async list(request) {
2691
+ const rows = await attempt(pool, listStatement(request, listTerms(request)), "read");
2692
+ if (!Array.isArray(rows)) return rows;
2693
+ const described = describedBy(rows[0]);
2694
+ if (described === "cut") return CUT;
2695
+ if (described === void 0) return NOT_DESCRIBED;
2696
+ return {
2697
+ ok: true,
2698
+ described,
2699
+ rows: rows.filter((row) => row.found !== null).map((row) => recordFrom(row, request.columns, request.target.concurrency))
2700
+ };
2701
+ },
2702
+ async insert(request) {
2703
+ checkAssigned(request.values, null);
2704
+ const rows = await attempt(pool, insertStatement(request, definitionBytes(request.definition)), "write", request.values);
2705
+ if (!Array.isArray(rows)) return rows;
2706
+ const row = rows[0];
2707
+ if (row === void 0) throw new Error("SQL Server reported no inserted row");
2708
+ return {
2709
+ ok: true,
2710
+ ...recordFrom(row, request.returning, request.target.concurrency)
2711
+ };
2712
+ },
2713
+ async update(request) {
2714
+ const { target } = request;
2715
+ const terms = rowFilterTerms(request.filters);
2716
+ const through = throughTerms(request.through);
2717
+ checkKey(target, request.key);
2718
+ if (request.set.length === 0) throw new Error("An update sets at least one column");
2719
+ checkAssigned(request.set, target.concurrency.column);
2720
+ const definition = definitionBytes(request.definition);
2721
+ const expected = expectedVersion(target.concurrency, request.expectedVersion);
2722
+ if (expected !== void 0) {
2723
+ const rows = await attempt(pool, updateStatement(request, terms, through, expected, definition), "write", request.set);
2724
+ if (!Array.isArray(rows)) return rows;
2725
+ const row = onlyRow(rows);
2726
+ if (row !== void 0) return {
2727
+ ok: true,
2728
+ ...recordFrom(row, request.returning, target.concurrency)
2729
+ };
2730
+ }
2731
+ const found = await attempt(pool, existsStatement(request, terms, through, definition), "read");
2732
+ if (!Array.isArray(found)) return found;
2733
+ if (Number(found[0]?.same ?? 0) !== 1) return DEFINITION_MOVED;
2734
+ return Number(found[0]?.found ?? 0) > 0 ? STALE : NOT_FOUND;
2735
+ },
2736
+ async delete(request) {
2737
+ const { target } = request;
2738
+ const terms = deleteTerms(request);
2739
+ checkKey(target, terms.key);
2740
+ const definition = definitionBytes(request.definition);
2741
+ const expected = expectedVersion(target.concurrency, terms.expectedVersion);
2742
+ if (expected !== void 0) {
2743
+ const rows = await attempt(pool, deleteStatement(request, terms, expected, definition), "write");
2744
+ if (!Array.isArray(rows)) return rows;
2745
+ const row = onlyRow(rows);
2746
+ if (row !== void 0) {
2747
+ const removed = recordFrom(row, target.identity, target.concurrency);
2748
+ return {
2749
+ ok: true,
2750
+ deleted: {
2751
+ values: removed.values,
2752
+ version: removed.version
2753
+ }
2754
+ };
2755
+ }
2756
+ }
2757
+ const found = await attempt(pool, existsStatement({
2758
+ target,
2759
+ key: terms.key
2760
+ }, terms.terms, terms.through, definition), "read");
2761
+ if (!Array.isArray(found)) return found;
2762
+ if (Number(found[0]?.same ?? 0) !== 1) return DEFINITION_MOVED;
2763
+ return Number(found[0]?.found ?? 0) > 0 ? STALE : NOT_FOUND;
2764
+ }
2765
+ };
2766
+ }
2767
+ //#endregion
2768
+ //#region src/connect.ts
2769
+ /**
2770
+ * A connected pool, from this package's own copy of mssql.
2771
+ *
2772
+ * Why the adapter package opens its own connections: mssql's parameter types
2773
+ * are objects tedious checks by identity, and this package binds every value
2774
+ * with `mssql.NVarChar` and friends from ITS copy. A pool built from another
2775
+ * copy — two installs of mssql@12.7.4 that differ only in which supports-color
2776
+ * satisfied an optional peer, which is exactly what pnpm produced for the
2777
+ * server — refuses every parameter with "type.validate is not a function".
2778
+ * Found by the end-to-end suite; no unit test could see it. Opening the pool
2779
+ * here means the pool and the types always come from one copy.
2780
+ */
2781
+ function connectSqlServer(options) {
2782
+ return new mssql.ConnectionPool({
2783
+ server: options.host,
2784
+ port: options.port,
2785
+ database: options.database,
2786
+ user: options.user,
2787
+ password: options.password,
2788
+ options: {
2789
+ encrypt: options.encrypt,
2790
+ trustServerCertificate: options.trustServerCertificate
2791
+ }
2792
+ }).connect();
2793
+ }
2794
+ //#endregion
2795
+ export { connectSqlServer, createSqlServerAdapter, createSqlServerLookups, createSqlServerRecords, discoverSqlServer };
2796
+
2797
+ //# sourceMappingURL=index.mjs.map