@eltjs/sdk-microsoft-sql-server 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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/dist/cells.d.ts +4 -0
  4. package/dist/cells.js +19 -0
  5. package/dist/change-set.d.ts +20 -0
  6. package/dist/change-set.js +21 -0
  7. package/dist/codecs/boolean-codec.d.ts +10 -0
  8. package/dist/codecs/boolean-codec.js +20 -0
  9. package/dist/codecs/bytes-codec.d.ts +12 -0
  10. package/dist/codecs/bytes-codec.js +31 -0
  11. package/dist/codecs/codec-for.d.ts +12 -0
  12. package/dist/codecs/codec-for.js +106 -0
  13. package/dist/codecs/date-codec.d.ts +9 -0
  14. package/dist/codecs/date-codec.js +15 -0
  15. package/dist/codecs/decimal-codec.d.ts +13 -0
  16. package/dist/codecs/decimal-codec.js +27 -0
  17. package/dist/codecs/fraction.d.ts +1 -0
  18. package/dist/codecs/fraction.js +7 -0
  19. package/dist/codecs/int64-codec.d.ts +11 -0
  20. package/dist/codecs/int64-codec.js +26 -0
  21. package/dist/codecs/integer-codec.d.ts +11 -0
  22. package/dist/codecs/integer-codec.js +23 -0
  23. package/dist/codecs/local-time-codec.d.ts +12 -0
  24. package/dist/codecs/local-time-codec.js +21 -0
  25. package/dist/codecs/local-timestamp-codec.d.ts +12 -0
  26. package/dist/codecs/local-timestamp-codec.js +25 -0
  27. package/dist/codecs/number-codec.d.ts +11 -0
  28. package/dist/codecs/number-codec.js +24 -0
  29. package/dist/codecs/spatial-codec.d.ts +8 -0
  30. package/dist/codecs/spatial-codec.js +14 -0
  31. package/dist/codecs/sql-server-codec.d.ts +41 -0
  32. package/dist/codecs/sql-server-codec.js +21 -0
  33. package/dist/codecs/text-codec.d.ts +11 -0
  34. package/dist/codecs/text-codec.js +24 -0
  35. package/dist/codecs/timestamp-codec.d.ts +14 -0
  36. package/dist/codecs/timestamp-codec.js +35 -0
  37. package/dist/codecs/variant-codec.d.ts +9 -0
  38. package/dist/codecs/variant-codec.js +20 -0
  39. package/dist/errors.d.ts +21 -0
  40. package/dist/errors.js +44 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.js +7 -0
  43. package/dist/quote-name.d.ts +1 -0
  44. package/dist/quote-name.js +4 -0
  45. package/dist/sql-server-column.d.ts +19 -0
  46. package/dist/sql-server-column.js +24 -0
  47. package/dist/sql-server-database.d.ts +8 -0
  48. package/dist/sql-server-database.js +65 -0
  49. package/dist/sql-server-request.d.ts +3 -0
  50. package/dist/sql-server-request.js +71 -0
  51. package/dist/sql-server-row.d.ts +10 -0
  52. package/dist/sql-server-row.js +25 -0
  53. package/dist/sql-server-session.d.ts +24 -0
  54. package/dist/sql-server-session.js +374 -0
  55. package/dist/sql-server-table.d.ts +23 -0
  56. package/dist/sql-server-table.js +36 -0
  57. package/dist/values.d.ts +11 -0
  58. package/dist/values.js +22 -0
  59. package/package.json +46 -4
@@ -0,0 +1,25 @@
1
+ export class SqlServerRow {
2
+ table;
3
+ // One value per readable column, in the table's column order.
4
+ values;
5
+ constructor(table, values) {
6
+ this.table = table;
7
+ this.values = values;
8
+ Object.freeze(this);
9
+ }
10
+ value(column) {
11
+ const index = this.table.columns.indexOf(column);
12
+ if (index === -1)
13
+ throw new TypeError(`${this.table.quoted} has no column ${column.name}`);
14
+ return this.values[index] ?? null;
15
+ }
16
+ // Where this row sits in primary key order, as text a page reads after.
17
+ get position() {
18
+ return this.table.primaryKey.map((column) => {
19
+ const value = this.value(column);
20
+ if (value === null)
21
+ throw new TypeError(`${this.table.quoted} key ${column.name} is null`);
22
+ return column.codec.position(value);
23
+ });
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ import sql from 'mssql';
2
+ import { type ChangePosition, ChangeSet } from './change-set.ts';
3
+ import { SqlServerRow } from './sql-server-row.ts';
4
+ import { SqlServerTable } from './sql-server-table.ts';
5
+ export type SqlServerVersion = {
6
+ readonly changeTracking: string | null;
7
+ readonly rowversion: string;
8
+ };
9
+ export declare class SqlServerSession implements AsyncDisposable {
10
+ #private;
11
+ constructor(pool: sql.ConnectionPool, location: string, snapshotIsolation: boolean);
12
+ [Symbol.asyncDispose](): Promise<void>;
13
+ tables(): Promise<SqlServerTable[]>;
14
+ version(): Promise<SqlServerVersion>;
15
+ statistics(): Promise<ReadonlyMap<string, string>>;
16
+ rowCounts(): Promise<ReadonlyMap<string, string>>;
17
+ lastWrites(): Promise<ReadonlyMap<string, string>>;
18
+ changeTrackingPosition(table: SqlServerTable): Promise<ChangePosition>;
19
+ minActiveRowversion(): Promise<string>;
20
+ page(table: SqlServerTable, after: readonly string[] | null, limit: number): AsyncGenerator<SqlServerRow>;
21
+ rowversions(table: SqlServerTable, above: string, below: string, limit: number): AsyncGenerator<SqlServerRow>;
22
+ rows(table: SqlServerTable): AsyncGenerator<SqlServerRow>;
23
+ changes(table: SqlServerTable, since: ChangePosition): Promise<ChangeSet>;
24
+ }
@@ -0,0 +1,374 @@
1
+ import sql from 'mssql';
2
+ import { cellBoolean, cellNullableText, cellNumber, cellText, } from './cells.js';
3
+ import { ChangeSet, } from './change-set.js';
4
+ import { codecFor, declaredType } from './codecs/codec-for.js';
5
+ import { ChangeHistoryExpiredError, SqlServerPermissionError, } from './errors.js';
6
+ import { SqlServerColumn } from './sql-server-column.js';
7
+ import { query, stream } from './sql-server-request.js';
8
+ import { SqlServerRow } from './sql-server-row.js';
9
+ import { SqlServerTable } from './sql-server-table.js';
10
+ // User tables only: not the system's own, not external tables (their rows
11
+ // live elsewhere), and not the change tables CDC keeps.
12
+ const tablesQuery = `
13
+ SELECT t.object_id, s.name, t.name,
14
+ CONVERT(nvarchar(max), ep.value),
15
+ CASE
16
+ WHEN ctt.object_id IS NULL THEN 'off'
17
+ WHEN HAS_PERMS_BY_NAME(QUOTENAME(s.name) + '.' + QUOTENAME(t.name), 'OBJECT', 'VIEW CHANGE TRACKING') = 1 THEN 'readable'
18
+ ELSE 'denied'
19
+ END
20
+ FROM sys.tables AS t
21
+ JOIN sys.schemas AS s ON s.schema_id = t.schema_id
22
+ LEFT JOIN sys.extended_properties AS ep
23
+ ON ep.class = 1 AND ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
24
+ LEFT JOIN sys.change_tracking_tables AS ctt ON ctt.object_id = t.object_id
25
+ WHERE t.is_ms_shipped = 0 AND t.is_external = 0 AND s.name <> 'cdc'
26
+ ORDER BY s.name, t.name`;
27
+ // For each table: how many statistics it has, the rows changed in their
28
+ // columns since each was last refreshed, and the last refresh.
29
+ const statisticsQuery = `
30
+ SELECT QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name),
31
+ CONCAT(COUNT(p.stats_id), ' ', SUM(p.modification_counter), ' ', CONVERT(varchar(27), MAX(p.last_updated), 126))
32
+ FROM sys.tables AS t
33
+ LEFT JOIN sys.stats AS st ON st.object_id = t.object_id
34
+ OUTER APPLY sys.dm_db_stats_properties(st.object_id, st.stats_id) AS p
35
+ GROUP BY t.schema_id, t.name`;
36
+ // For each table: the rows of its heap or clustered index, in every
37
+ // partition.
38
+ const rowCountsQuery = `
39
+ SELECT QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name),
40
+ CONVERT(varchar(20), SUM(p.rows))
41
+ FROM sys.tables AS t
42
+ JOIN sys.partitions AS p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
43
+ GROUP BY t.schema_id, t.name`;
44
+ // For each table: the writes counted on its indexes since the engine
45
+ // started, and the last. A table with no usage row yet counts 0 writes, as
46
+ // it does after the first read makes its row, so a read moves nothing.
47
+ const lastWritesQuery = `
48
+ SELECT QUOTENAME(SCHEMA_NAME(t.schema_id)) + '.' + QUOTENAME(t.name),
49
+ CONCAT(COALESCE(SUM(u.user_updates), 0), ' ', CONVERT(varchar(23), MAX(u.last_user_update), 126))
50
+ FROM sys.tables AS t
51
+ LEFT JOIN sys.dm_db_index_usage_stats AS u
52
+ ON u.object_id = t.object_id AND u.database_id = DB_ID()
53
+ GROUP BY t.schema_id, t.name`;
54
+ // TYPE_NAME(system_type_id) is the system type an alias type is built on; a
55
+ // login that cannot see an alias type would otherwise lose its columns. CLR
56
+ // types share one system type, so they keep their own name.
57
+ const columnsQuery = `
58
+ SELECT c.object_id, c.name, COALESCE(TYPE_NAME(c.system_type_id), ty.name),
59
+ CAST(COALESCE(ty.is_assembly_type, CASE WHEN c.system_type_id = 240 THEN 1 ELSE 0 END) AS bit),
60
+ CAST(c.max_length AS int), CAST(c.precision AS int), CAST(c.scale AS int),
61
+ c.is_nullable, c.is_masked, c.collation_name,
62
+ CONVERT(nvarchar(max), ep.value),
63
+ HAS_PERMS_BY_NAME(QUOTENAME(OBJECT_SCHEMA_NAME(c.object_id)) + '.' + QUOTENAME(OBJECT_NAME(c.object_id)), 'OBJECT', 'SELECT', c.name, 'COLUMN')
64
+ FROM sys.columns AS c
65
+ JOIN sys.tables AS t ON t.object_id = c.object_id
66
+ LEFT JOIN sys.types AS ty ON ty.user_type_id = c.user_type_id
67
+ LEFT JOIN sys.extended_properties AS ep
68
+ ON ep.class = 1 AND ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
69
+ WHERE t.is_ms_shipped = 0
70
+ ORDER BY c.object_id, c.column_id`;
71
+ // A table's tracking generation. A truncate starts its history over at the
72
+ // current version (begin_version) and re-enabling tracking recreates its
73
+ // change table (create_date); either way the changes before it are gone, and
74
+ // the minimum valid version alone does not show it when nothing else changed.
75
+ const generationQuery = `(SELECT CONVERT(varchar(20), ctt.begin_version) + '@' + CONVERT(varchar(30), it.create_date, 126)
76
+ FROM sys.change_tracking_tables AS ctt
77
+ JOIN sys.internal_tables AS it ON it.parent_object_id = ctt.object_id AND it.internal_type_desc = 'CHANGE_TRACKING'
78
+ WHERE ctt.object_id = OBJECT_ID(@p0))`;
79
+ const primaryKeysQuery = `
80
+ SELECT ic.object_id, c.name
81
+ FROM sys.indexes AS i
82
+ JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
83
+ JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
84
+ WHERE i.is_primary_key = 1
85
+ ORDER BY ic.object_id, ic.key_ordinal`;
86
+ // One run's connections to the database. Reads hold no transaction between
87
+ // statements, except one short snapshot transaction around a Change
88
+ // Tracking delta when the database allows snapshot isolation.
89
+ export class SqlServerSession {
90
+ #pool;
91
+ #location;
92
+ // ALLOW_SNAPSHOT_ISOLATION is on, so a delta can read in one snapshot.
93
+ #snapshot;
94
+ constructor(pool, location, snapshotIsolation) {
95
+ this.#pool = pool;
96
+ this.#location = location;
97
+ this.#snapshot = snapshotIsolation;
98
+ Object.freeze(this);
99
+ }
100
+ async [Symbol.asyncDispose]() {
101
+ await this.#pool.close();
102
+ }
103
+ // Every user table, with the columns, primary key and Change Tracking the
104
+ // login can read.
105
+ async tables() {
106
+ const [tables, columns, keys] = await Promise.all([
107
+ query(this.#pool.request(), tablesQuery),
108
+ query(this.#pool.request(), columnsQuery),
109
+ query(this.#pool.request(), primaryKeysQuery),
110
+ ]);
111
+ const columnsOf = groupBy(columns);
112
+ const keysOf = groupBy(keys);
113
+ return tables.map(([id, schema, name, description, changeTracking]) => {
114
+ const readable = [];
115
+ const unreadable = [];
116
+ for (const row of columnsOf.get(cellNumber(id, 'object_id')) ?? []) {
117
+ const [, columnName, typeName, clr, maxLength, precision, scale, nullable, masked, collation, comment, granted,] = row;
118
+ const type = {
119
+ name: cellNullableText(typeName, 'type'),
120
+ clr: cellBoolean(clr, 'is_assembly_type'),
121
+ maxLength: cellNumber(maxLength, 'max_length'),
122
+ precision: cellNumber(precision, 'precision'),
123
+ scale: cellNumber(scale, 'scale'),
124
+ collation: cellNullableText(collation, 'collation_name'),
125
+ };
126
+ const column = cellText(columnName, 'column name');
127
+ if (granted !== 1) {
128
+ unreadable.push(column);
129
+ continue;
130
+ }
131
+ readable.push(new SqlServerColumn({
132
+ name: column,
133
+ type: declaredType(type),
134
+ nullable: cellBoolean(nullable, 'is_nullable'),
135
+ masked: cellBoolean(masked, 'is_masked'),
136
+ description: cellNullableText(comment, 'MS_Description'),
137
+ codec: codecFor(type),
138
+ }));
139
+ }
140
+ const keyNames = (keysOf.get(cellNumber(id, 'object_id')) ?? []).map(([, key]) => cellText(key, 'key column'));
141
+ const primaryKey = keyNames.flatMap((key) => readable.find((column) => column.name === key) ?? []);
142
+ return new SqlServerTable({
143
+ schema: cellText(schema, 'schema'),
144
+ name: cellText(name, 'table'),
145
+ description: cellNullableText(description, 'MS_Description'),
146
+ columns: readable,
147
+ unreadable,
148
+ // A key the login cannot read in full cannot identify rows.
149
+ primaryKey: primaryKey.length === keyNames.length ? primaryKey : [],
150
+ changeTracking: trackingState(cellText(changeTracking, 'change tracking')),
151
+ });
152
+ });
153
+ }
154
+ async version() {
155
+ const [[changeTracking, rowversion] = []] = await query(this.#pool.request(), 'SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), CONVERT(varchar(20), CAST(@@DBTS AS bigint))');
156
+ return Object.freeze({
157
+ changeTracking: cellNullableText(changeTracking, 'change tracking version'),
158
+ rowversion: cellText(rowversion, '@@DBTS'),
159
+ });
160
+ }
161
+ // Where the statistics of each table stand, by its quoted name: SQL Server
162
+ // counts the rows changed in each column that has statistics, and keeps the
163
+ // count through a restart. A refresh of statistics sets their count back to
164
+ // 0, so the value carries the time of the last refresh too. A table with no
165
+ // statistics has no count, so its value never moves. A login that can read
166
+ // a table can read its counts.
167
+ async statistics() {
168
+ const rows = await query(this.#pool.request(), statisticsQuery);
169
+ return new Map(rows.map(([table, counts]) => [
170
+ cellText(table, 'table'),
171
+ cellText(counts, 'statistics'),
172
+ ]));
173
+ }
174
+ // How many rows each table holds, by its quoted name, as SQL Server keeps
175
+ // it in the table's metadata: an insert, a delete or a TRUNCATE moves it,
176
+ // also in a table whose statistics SQL Server has not built, but an update
177
+ // does not. A login that can read a table can read its count.
178
+ async rowCounts() {
179
+ const rows = await query(this.#pool.request(), rowCountsQuery);
180
+ return new Map(rows.map(([table, count]) => [
181
+ cellText(table, 'table'),
182
+ cellText(count, 'row count'),
183
+ ]));
184
+ }
185
+ // When SQL Server last wrote to each table, by its quoted name, and how
186
+ // many writes it counted: any insert, update or delete moves it, also of a
187
+ // column with no statistics, but not a TRUNCATE. A restart forgets both,
188
+ // so the value of each table is then empty. A login needs VIEW SERVER
189
+ // STATE to read them (VIEW DATABASE STATE on Azure SQL Database).
190
+ async lastWrites() {
191
+ const rows = await query(this.#pool.request(), lastWritesQuery).catch((error) => {
192
+ // 297: the login may not read the state of the server or database.
193
+ if (error instanceof sql.RequestError && error.number === 297)
194
+ throw new SqlServerPermissionError('VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL Database)', error);
195
+ throw error;
196
+ });
197
+ return new Map(rows.map(([table, writes]) => [
198
+ cellText(table, 'table'),
199
+ cellText(writes, 'last write'),
200
+ ]));
201
+ }
202
+ // Where a full read of the table starts in its change history: read it
203
+ // before the rows, so a change made during the read is read again, never
204
+ // missed.
205
+ async changeTrackingPosition(table) {
206
+ const [[version, generation] = []] = await query(this.#pool.request(), `SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), ${generationQuery}`, [table.quoted]);
207
+ if (version === null || generation === null)
208
+ throw new TypeError(`Change Tracking is off for ${table.quoted} in ${this.#location}`);
209
+ return Object.freeze({
210
+ version: cellText(version, 'change tracking version'),
211
+ generation: cellText(generation, 'change tracking generation'),
212
+ });
213
+ }
214
+ // Rows below this rowversion are committed; one at or above it may belong
215
+ // to a transaction still open, so a read stops short of it.
216
+ async minActiveRowversion() {
217
+ const [[value] = []] = await query(this.#pool.request(), 'SELECT CONVERT(varchar(20), CAST(MIN_ACTIVE_ROWVERSION() AS bigint))');
218
+ return cellText(value, 'MIN_ACTIVE_ROWVERSION');
219
+ }
220
+ // Up to limit rows in primary key order, after the given position. The
221
+ // whole key compares as a tuple, each part cast to its column's type, so
222
+ // pages neither skip nor repeat rows that share a leading key value.
223
+ async *page(table, after, limit) {
224
+ if (!table.pageable)
225
+ throw new TypeError(`${table.quoted} has no primary key a read can page by`);
226
+ const keys = table.primaryKey;
227
+ const key = (index) => `t.${keys[index]?.quoted}`;
228
+ const cast = (index) => keys[index]?.codec.cast(`@p${index}`);
229
+ const where = after === null
230
+ ? ''
231
+ : `WHERE ${keys
232
+ .map((_, index) => `(${[
233
+ ...keys
234
+ .slice(0, index)
235
+ .map((__, prior) => `${key(prior)} = ${cast(prior)}`),
236
+ `${key(index)} > ${cast(index)}`,
237
+ ].join(' AND ')})`)
238
+ .join(' OR ')}`;
239
+ yield* this.#rows(table, `SELECT TOP (${count(limit)}) ${selection(table)} FROM ${table.quoted} AS t ${where} ORDER BY ${keys.map((_, index) => key(index)).join(', ')}`, after ?? []);
240
+ }
241
+ // Up to limit rows whose rowversion is above `above` and below `below`, in
242
+ // rowversion order.
243
+ async *rowversions(table, above, below, limit) {
244
+ const column = table.rowversion;
245
+ if (column === undefined)
246
+ throw new TypeError(`${table.quoted} has no rowversion column`);
247
+ const version = `t.${column.quoted}`;
248
+ yield* this.#rows(table, `SELECT TOP (${count(limit)}) ${selection(table)} FROM ${table.quoted} AS t WHERE ${version} > ${column.codec.cast('@p0')} AND ${version} < ${column.codec.cast('@p1')} ORDER BY ${version}`, [above, below]);
249
+ }
250
+ // Every row, in no particular order.
251
+ async *rows(table) {
252
+ yield* this.#rows(table, `SELECT ${selection(table)} FROM ${table.quoted} AS t`, []);
253
+ }
254
+ // What changed in the table since a position in its change history: each
255
+ // changed key once, with its row as it is now, or as a deletion. A position
256
+ // from another generation, at the start of its generation, or outside the
257
+ // history kept, has expired. With snapshot isolation the version, the check
258
+ // and the changes come from one snapshot. Without it the version is read
259
+ // first, so a change made during the read is read again next time, and the
260
+ // check runs again after the read in case cleanup removed history
261
+ // meanwhile.
262
+ async changes(table, since) {
263
+ if (table.primaryKey.length === 0)
264
+ throw new TypeError(`${table.quoted} has no primary key to track changes by`);
265
+ const transaction = this.#snapshot
266
+ ? new sql.Transaction(this.#pool)
267
+ : undefined;
268
+ await transaction?.begin(sql.ISOLATION_LEVEL.SNAPSHOT);
269
+ let open = transaction !== undefined;
270
+ const close = async () => {
271
+ if (!open)
272
+ return;
273
+ open = false;
274
+ await transaction?.rollback();
275
+ };
276
+ const request = () => transaction === undefined
277
+ ? this.#pool.request()
278
+ : new sql.Request(transaction);
279
+ const check = async () => {
280
+ const [[current, minimum, viewable, generation, begin] = []] = await query(request(), `SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), CONVERT(varchar(20), CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID(@p0))), HAS_PERMS_BY_NAME(@p0, 'OBJECT', 'VIEW CHANGE TRACKING'), ${generationQuery}, (SELECT CONVERT(varchar(20), begin_version) FROM sys.change_tracking_tables WHERE object_id = OBJECT_ID(@p0))`, [table.quoted]);
281
+ // Without the grant SQL Server still reports a minimum valid version
282
+ // (0), so the grant itself is checked first.
283
+ if (viewable !== 1)
284
+ throw new SqlServerPermissionError(`VIEW CHANGE TRACKING ON ${table.quoted}`);
285
+ if (minimum === null || generation === null)
286
+ throw new TypeError(`Change Tracking is off for ${table.quoted}`);
287
+ const version = cellText(current, 'change tracking version');
288
+ // A TRUNCATE moves begin_version to the current version. When the
289
+ // generation began at the saved version, a truncate since moves it
290
+ // nowhere, so such a position cannot show one.
291
+ if (generation !== since.generation ||
292
+ BigInt(since.version) <= BigInt(cellText(begin, 'begin version')) ||
293
+ BigInt(since.version) <
294
+ BigInt(cellText(minimum, 'minimum valid version')) ||
295
+ BigInt(since.version) > BigInt(version))
296
+ throw new ChangeHistoryExpiredError(table.quoted, since.version);
297
+ return version;
298
+ };
299
+ try {
300
+ const version = await check();
301
+ const keys = table.primaryKey;
302
+ const present = `CASE WHEN t.${keys[0]?.quoted} IS NULL THEN 0 ELSE 1 END`;
303
+ const changed = keys.flatMap((column) => column.codec.select(`ct.${column.quoted}`));
304
+ const text = `DECLARE @since bigint = CAST(@p0 AS bigint);
305
+ SELECT ${present}, ${changed.join(', ')}, ${selection(table)}
306
+ FROM CHANGETABLE(CHANGES ${table.quoted}, @since) AS ct
307
+ LEFT JOIN ${table.quoted} AS t ON ${keys.map((column) => `t.${column.quoted} = ct.${column.quoted}`).join(' AND ')}`;
308
+ const changes = async function* () {
309
+ for await (const values of stream(request(), text, [since.version])) {
310
+ const [exists] = values;
311
+ const key = decode(keys, values.slice(1, 1 + changed.length));
312
+ yield exists === 1
313
+ ? {
314
+ type: 'upsert',
315
+ row: new SqlServerRow(table, decode(table.columns, values.slice(1 + changed.length))),
316
+ }
317
+ : { type: 'delete', key };
318
+ }
319
+ if (transaction === undefined)
320
+ await check();
321
+ else {
322
+ open = false;
323
+ await transaction.commit();
324
+ }
325
+ };
326
+ // A read stopped early, or failed, leaves the snapshot to roll back.
327
+ return new ChangeSet(Object.freeze({ version, generation: since.generation }), changes(), close);
328
+ }
329
+ catch (error) {
330
+ await close();
331
+ throw error;
332
+ }
333
+ }
334
+ async *#rows(table, text, parameters) {
335
+ for await (const values of stream(this.#pool.request(), text, parameters))
336
+ yield new SqlServerRow(table, decode(table.columns, values));
337
+ }
338
+ }
339
+ function trackingState(value) {
340
+ if (value === 'off' || value === 'readable' || value === 'denied')
341
+ return value;
342
+ throw new TypeError(`Unknown Change Tracking state ${value}`);
343
+ }
344
+ function count(limit) {
345
+ if (!Number.isSafeInteger(limit) || limit < 1)
346
+ throw new TypeError(`A page holds at least one row, not ${limit}`);
347
+ return limit;
348
+ }
349
+ // The expressions that read every column exactly, in column order.
350
+ function selection(table) {
351
+ return table.columns
352
+ .flatMap((column) => column.codec.select(`t.${column.quoted}`))
353
+ .join(', ');
354
+ }
355
+ // Each column's value from the expressions its codec selected.
356
+ function decode(columns, values) {
357
+ let offset = 0;
358
+ return columns.map(({ codec }) => {
359
+ const width = codec.select('x').length;
360
+ const value = codec.decode(values.slice(offset, offset + width));
361
+ offset += width;
362
+ return value;
363
+ });
364
+ }
365
+ function groupBy(rows) {
366
+ const groups = new Map();
367
+ for (const row of rows) {
368
+ const id = cellNumber(row[0], 'object_id');
369
+ const group = groups.get(id) ?? [];
370
+ group.push(row);
371
+ groups.set(id, group);
372
+ }
373
+ return groups;
374
+ }
@@ -0,0 +1,23 @@
1
+ import type { SqlServerColumn } from './sql-server-column.ts';
2
+ export type ChangeTracking = 'off' | 'readable' | 'denied';
3
+ export declare class SqlServerTable {
4
+ readonly schema: string;
5
+ readonly name: string;
6
+ readonly description: string | null;
7
+ readonly columns: readonly SqlServerColumn[];
8
+ readonly unreadable: readonly string[];
9
+ readonly primaryKey: readonly SqlServerColumn[];
10
+ readonly changeTracking: ChangeTracking;
11
+ constructor(table: {
12
+ schema: string;
13
+ name: string;
14
+ description: string | null;
15
+ columns: readonly SqlServerColumn[];
16
+ unreadable: readonly string[];
17
+ primaryKey: readonly SqlServerColumn[];
18
+ changeTracking: ChangeTracking;
19
+ });
20
+ get quoted(): string;
21
+ get rowversion(): SqlServerColumn | undefined;
22
+ get pageable(): boolean;
23
+ }
@@ -0,0 +1,36 @@
1
+ import { quoteName } from './quote-name.js';
2
+ export class SqlServerTable {
3
+ schema;
4
+ name;
5
+ // Its MS_Description extended property.
6
+ description;
7
+ // The columns the login can read, in their declared order.
8
+ columns;
9
+ // The columns a column-level DENY keeps from the login.
10
+ unreadable;
11
+ // Empty when the table has no primary key, or the login cannot read all of it.
12
+ primaryKey;
13
+ changeTracking;
14
+ constructor(table) {
15
+ this.schema = table.schema;
16
+ this.name = table.name;
17
+ this.description = table.description;
18
+ this.columns = Object.freeze([...table.columns]);
19
+ this.unreadable = Object.freeze([...table.unreadable]);
20
+ this.primaryKey = Object.freeze([...table.primaryKey]);
21
+ this.changeTracking = table.changeTracking;
22
+ Object.freeze(this);
23
+ }
24
+ get quoted() {
25
+ return `${quoteName(this.schema)}.${quoteName(this.name)}`;
26
+ }
27
+ // The rowversion column, which SQL Server sets on every insert and update.
28
+ get rowversion() {
29
+ return this.columns.find((column) => column.type === 'timestamp');
30
+ }
31
+ // Whether reads can page through the table in primary key order.
32
+ get pageable() {
33
+ return (this.primaryKey.length > 0 &&
34
+ this.primaryKey.every((column) => column.codec.cast('@p') !== undefined));
35
+ }
36
+ }
@@ -0,0 +1,11 @@
1
+ export declare class OffsetTimestamp {
2
+ readonly instant: string;
3
+ readonly offset: number;
4
+ constructor(instant: string, offset: number);
5
+ }
6
+ export declare class VariantValue {
7
+ readonly text: string;
8
+ readonly type: string;
9
+ constructor(text: string, type: string);
10
+ }
11
+ export type SqlServerValue = string | number | boolean | OffsetTimestamp | VariantValue | null;
package/dist/values.js ADDED
@@ -0,0 +1,22 @@
1
+ // A datetimeoffset value: its UTC instant, and the offset in minutes it was
2
+ // written with, which the instant alone loses.
3
+ export class OffsetTimestamp {
4
+ instant;
5
+ offset;
6
+ constructor(instant, offset) {
7
+ this.instant = instant;
8
+ this.offset = offset;
9
+ Object.freeze(this);
10
+ }
11
+ }
12
+ // A sql_variant value: its text, and the base type it holds, which decides
13
+ // how to read the text.
14
+ export class VariantValue {
15
+ text;
16
+ type;
17
+ constructor(text, type) {
18
+ this.text = text;
19
+ this.type = type;
20
+ Object.freeze(this);
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@eltjs/sdk-microsoft-sql-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reads one SQL Server database: discovery, exact reads, Change Tracking and change signals. It knows nothing of elt.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ezzabuzaid/elt",
9
+ "directory": "packages/sdks/microsoft/sql-server"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/ezzabuzaid/elt/issues"
13
+ },
14
+ "homepage": "https://github.com/ezzabuzaid/elt/tree/main/packages/sdks/microsoft/sql-server#readme",
15
+ "keywords": [
16
+ "sql-server",
17
+ "mssql",
18
+ "azure-sql",
19
+ "change-tracking"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ "./package.json": "./package.json",
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/spec/**",
32
+ "!**/*.tsbuildinfo",
33
+ "!**/*.test.*"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=24"
40
+ },
41
+ "dependencies": {
42
+ "@types/mssql": "^12.3.0",
43
+ "mssql": "^12.7.4"
44
+ },
45
+ "devDependencies": {
46
+ "@zukhruf/testing": "^0.8.10"
47
+ }
48
+ }