@eltjs/sdk-microsoft-sql-server 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ for (const table of await session.tables()) {
18
18
  - `SqlServerDatabase` takes an ADO.NET connection string. The string must name its server and its database. An `mssql://` URL does not work.
19
19
  - `open` gives a `SqlServerSession`. Dispose the session to close its connections.
20
20
  - `tables` gives each user table that the login can read. A view is not in the list. Each `SqlServerTable` has its columns, its primary key and its Change Tracking state. `unreadable` names the columns that the login cannot read.
21
+ - `rows`, `page`, `rowversions` and `changes` take a `SqlServerRead`: a `clock`, which an app gives as `globalThis`, and a `signal`, which stops the read also when the server sends nothing. A read that gets no row between two checks an hour apart fails with `SqlServerUnavailableError`.
21
22
  - `rows`, `page` and `rowversions` read rows of a table. `changes` gives the rows that changed after a `ChangePosition`, each row once.
22
23
  - `version`, `statistics`, `rowCounts` and `lastWrites` give the values that show that a table changed.
23
24
 
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { type ChangePosition, type SqlServerChange } from './change-set.ts';
2
2
  export { ChangeHistoryExpiredError, SqlServerLoginError, SqlServerPermissionError, SqlServerUnavailableError, SqlServerVersionError, } from './errors.ts';
3
3
  export { SqlServerColumn } from './sql-server-column.ts';
4
4
  export { SqlServerDatabase } from './sql-server-database.ts';
5
+ export { type SqlServerRead } from './sql-server-request.ts';
5
6
  export { SqlServerRow } from './sql-server-row.ts';
6
7
  export { SqlServerSession } from './sql-server-session.ts';
7
8
  export { SqlServerTable } from './sql-server-table.ts';
@@ -1,7 +1,7 @@
1
1
  import sql from 'mssql';
2
2
  import { cellNumber } from './cells.js';
3
- import { SqlServerLoginError, SqlServerUnavailableError, SqlServerVersionError, } from './errors.js';
4
- import { query } from './sql-server-request.js';
3
+ import { SqlServerVersionError } from './errors.js';
4
+ import { connectionFailure, query } from './sql-server-request.js';
5
5
  import { SqlServerSession } from './sql-server-session.js';
6
6
  // One database on a SQL Server, named by an ADO.NET connection string
7
7
  // (Server=host,port;Database=name;User Id=...;Password=...), the form SQL
@@ -23,7 +23,12 @@ export class SqlServerDatabase {
23
23
  this.location = `${config.server}${instance ? `\\${instance}` : ''}:${config.port ?? 1433}/${config.database}`;
24
24
  this.name = config.database;
25
25
  // A read streams a whole table, so no time limit fits every table.
26
- this.#config = { ...config, requestTimeout: 0, pool: { min: 0, max: 4 } };
26
+ this.#config = {
27
+ ...config,
28
+ requestTimeout: 0,
29
+ pool: { min: 0, max: 4 },
30
+ beforeConnect: markBusyLogin,
31
+ };
27
32
  Object.freeze(this);
28
33
  }
29
34
  async open() {
@@ -32,12 +37,10 @@ export class SqlServerDatabase {
32
37
  await pool.connect();
33
38
  }
34
39
  catch (cause) {
35
- throw refused(cause)
36
- ? new SqlServerLoginError(this.location, cause)
37
- : new SqlServerUnavailableError(this.location, cause);
40
+ throw connectionFailure(this.location, cause);
38
41
  }
39
42
  try {
40
- const [[edition, major, snapshot] = []] = await query(pool.request(), `SELECT CAST(SERVERPROPERTY('EngineEdition') AS int), CAST(SERVERPROPERTY('ProductMajorVersion') AS int), CAST(snapshot_isolation_state AS int) FROM sys.databases WHERE database_id = DB_ID()`);
43
+ const [[edition, major, snapshot] = []] = await query(pool.request(), this.location, `SELECT CAST(SERVERPROPERTY('EngineEdition') AS int), CAST(SERVERPROPERTY('ProductMajorVersion') AS int), CAST(snapshot_isolation_state AS int) FROM sys.databases WHERE database_id = DB_ID()`);
41
44
  const version = cellNumber(major, 'ProductMajorVersion');
42
45
  // Azure SQL Database (5) and Managed Instance (8) report version 12
43
46
  // but run the current engine.
@@ -51,15 +54,20 @@ export class SqlServerDatabase {
51
54
  }
52
55
  }
53
56
  }
54
- // Whether the server answered and refused the login. tedious marks the login
55
- // errors SQL Server raises while it fails over or is busy (4060, 10928,
56
- // 10929, 40197, 40501, 40613) as transient, and tries them again before it
57
- // gives up; those mean the server cannot be reached now.
58
- function refused(error) {
59
- if (!(error instanceof sql.ConnectionError) || error.code !== 'ELOGIN')
60
- return false;
61
- const { originalError } = error;
62
- return !(originalError !== undefined &&
63
- 'isTransient' in originalError &&
64
- originalError.isTransient === true);
57
+ // The login errors SQL Server raises while it fails over or is busy.
58
+ const busyLoginErrors = new Set([4060, 10928, 10929, 40197, 40501, 40613]);
59
+ // Marks a failed login as transient when the server raised one of those errors
60
+ // on the way. tedious keeps only the last error of a login, and SQL Server
61
+ // follows 4060 with 18456, which alone means a refused login. tedious also
62
+ // leaves its own mark off the error of its last retry, so a database that stays
63
+ // unavailable through its retries would read as refused.
64
+ function markBusyLogin(connection) {
65
+ let busy = false;
66
+ connection.on('errorMessage', ({ number }) => {
67
+ busy ||= busyLoginErrors.has(number);
68
+ });
69
+ connection.once('connect', (error) => {
70
+ if (error !== undefined && busy)
71
+ error.isTransient = true;
72
+ });
65
73
  }
@@ -1,3 +1,11 @@
1
1
  import sql from 'mssql';
2
- export declare function query(request: sql.Request, text: string, parameters?: readonly string[]): Promise<unknown[][]>;
3
- export declare function stream(request: sql.Request, text: string, parameters?: readonly string[]): AsyncGenerator<unknown[]>;
2
+ export type SqlServerRead = {
3
+ readonly clock: {
4
+ setTimeout(callback: () => void, ms: number): unknown;
5
+ clearTimeout(timer: unknown): void;
6
+ };
7
+ readonly signal: AbortSignal | undefined;
8
+ };
9
+ export declare function connectionFailure(location: string, cause: unknown): Error;
10
+ export declare function query(request: sql.Request, location: string, text: string, parameters?: readonly string[]): Promise<unknown[][]>;
11
+ export declare function stream(request: sql.Request, text: string, parameters: readonly string[], { clock, signal }: SqlServerRead, location: string): AsyncGenerator<unknown[]>;
@@ -1,5 +1,12 @@
1
1
  import sql from 'mssql';
2
- import { SqlServerPermissionError } from './errors.js';
2
+ import { SqlServerLoginError, SqlServerPermissionError, SqlServerUnavailableError, } from './errors.js';
3
+ // How often a read checks that rows still arrive. A read that got no row from
4
+ // one check to the next fails, so a server or network that stopped
5
+ // sending fails it within two hours, the limit of Airbyte's source-mssql
6
+ // (maxSecondsBetweenMessages: 7200 in its metadata.yaml). The driver has no such
7
+ // limit: its request timeout stops at the first data, and a read of a whole
8
+ // table can take any length of time.
9
+ const silenceCheckMs = 3_600_000;
3
10
  // Every parameter travels as text and the statement casts it, so a value
4
11
  // keeps the exact spelling a read gave it, whatever the driver would make of
5
12
  // a number or a Date.
@@ -7,8 +14,35 @@ function bind(request, parameters) {
7
14
  for (const [index, value] of parameters.entries())
8
15
  request.input(`p${index}`, sql.NVarChar(sql.MAX), value);
9
16
  }
10
- // A refused read names the grant it needs (errors 229 and 230).
11
- function readError(error) {
17
+ // What a failed connection means, when the pool opened it or a statement lost
18
+ // it: a login the server refused is the user's to fix, and anything else can
19
+ // succeed at a later attempt.
20
+ export function connectionFailure(location, cause) {
21
+ return refused(cause)
22
+ ? new SqlServerLoginError(location, cause)
23
+ : new SqlServerUnavailableError(location, cause);
24
+ }
25
+ // Whether the server answered and refused the login. Its errors while it fails
26
+ // over or is busy are marked as transient when the connection opens
27
+ // (SqlServerDatabase); those mean the server cannot be reached now.
28
+ function refused(error) {
29
+ if (!(error instanceof sql.ConnectionError) || error.code !== 'ELOGIN')
30
+ return false;
31
+ const { originalError } = error;
32
+ return !(originalError !== undefined &&
33
+ 'isTransient' in originalError &&
34
+ originalError.isTransient === true);
35
+ }
36
+ // A failed statement as what it means. The connection failed under it when
37
+ // the error has no server number, such as ECONNRESET, or when the server ended
38
+ // the session: SQL Server ends the connection at severity 20 and above, as
39
+ // error 596 does for a session that was killed. A refused read names the grant
40
+ // it needs (errors 229 and 230).
41
+ function readError(error, location) {
42
+ if (error instanceof sql.ConnectionError ||
43
+ (error instanceof sql.RequestError &&
44
+ (typeof error.number !== 'number' || Number(error.class) >= 20)))
45
+ return connectionFailure(location, error);
12
46
  if (!(error instanceof sql.RequestError))
13
47
  return error;
14
48
  if (error.number !== 229 && error.number !== 230)
@@ -26,7 +60,7 @@ function asRow(row) {
26
60
  return row;
27
61
  }
28
62
  // Every row of one statement, each as the values it selects in order.
29
- export async function query(request, text, parameters = []) {
63
+ export async function query(request, location, text, parameters = []) {
30
64
  request.arrayRowMode = true;
31
65
  bind(request, parameters);
32
66
  try {
@@ -34,12 +68,14 @@ export async function query(request, text, parameters = []) {
34
68
  return recordset.map(asRow);
35
69
  }
36
70
  catch (error) {
37
- throw readError(error);
71
+ throw readError(error, location);
38
72
  }
39
73
  }
40
74
  // The rows of one statement as the driver receives them; stopping early
41
- // cancels the statement so its connection returns to the pool.
42
- export async function* stream(request, text, parameters = []) {
75
+ // cancels the statement so its connection returns to the pool. The signal stops
76
+ // the read also while the driver has no row to give.
77
+ export async function* stream(request, text, parameters, { clock, signal }, location) {
78
+ signal?.throwIfAborted();
43
79
  request.arrayRowMode = true;
44
80
  bind(request, parameters);
45
81
  const rows = request.toReadableStream();
@@ -49,17 +85,32 @@ export async function* stream(request, text, parameters = []) {
49
85
  void ended.then(() => {
50
86
  finished = true;
51
87
  });
88
+ let arrived = false;
89
+ const check = () => {
90
+ if (arrived) {
91
+ arrived = false;
92
+ timer = clock.setTimeout(check, silenceCheckMs);
93
+ }
94
+ else
95
+ rows.destroy(new SqlServerUnavailableError(location, new Error(`The server sent no row for over ${silenceCheckMs} ms`)));
96
+ };
97
+ let timer = clock.setTimeout(check, silenceCheckMs);
98
+ const stop = () => rows.destroy(signal?.reason);
99
+ signal?.addEventListener('abort', stop, { once: true });
52
100
  // In stream mode the promise always resolves; failures arrive on rows.
53
101
  void request.query(text);
54
102
  try {
55
103
  for await (const row of rows) {
104
+ arrived = true;
56
105
  yield asRow(row);
57
106
  }
58
107
  }
59
108
  catch (error) {
60
- throw readError(error);
109
+ throw readError(error, location);
61
110
  }
62
111
  finally {
112
+ clock.clearTimeout(timer);
113
+ signal?.removeEventListener('abort', stop);
63
114
  // A reader that stops early cancels the statement, and waits for it to
64
115
  // end: until then the connection, or a transaction on it, is not free.
65
116
  if (!finished) {
@@ -1,5 +1,6 @@
1
1
  import sql from 'mssql';
2
2
  import { type ChangePosition, ChangeSet } from './change-set.ts';
3
+ import { type SqlServerRead } from './sql-server-request.ts';
3
4
  import { SqlServerRow } from './sql-server-row.ts';
4
5
  import { SqlServerTable } from './sql-server-table.ts';
5
6
  export type SqlServerVersion = {
@@ -17,8 +18,8 @@ export declare class SqlServerSession implements AsyncDisposable {
17
18
  lastWrites(): Promise<ReadonlyMap<string, string>>;
18
19
  changeTrackingPosition(table: SqlServerTable): Promise<ChangePosition>;
19
20
  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>;
21
+ page(table: SqlServerTable, after: readonly string[] | null, limit: number, read: SqlServerRead): AsyncGenerator<SqlServerRow>;
22
+ rowversions(table: SqlServerTable, above: string, below: string, limit: number, read: SqlServerRead): AsyncGenerator<SqlServerRow>;
23
+ rows(table: SqlServerTable, read: SqlServerRead): AsyncGenerator<SqlServerRow>;
24
+ changes(table: SqlServerTable, since: ChangePosition, read: SqlServerRead): Promise<ChangeSet>;
24
25
  }
@@ -104,9 +104,9 @@ export class SqlServerSession {
104
104
  // login can read.
105
105
  async tables() {
106
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),
107
+ query(this.#pool.request(), this.#location, tablesQuery),
108
+ query(this.#pool.request(), this.#location, columnsQuery),
109
+ query(this.#pool.request(), this.#location, primaryKeysQuery),
110
110
  ]);
111
111
  const columnsOf = groupBy(columns);
112
112
  const keysOf = groupBy(keys);
@@ -152,7 +152,7 @@ export class SqlServerSession {
152
152
  });
153
153
  }
154
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))');
155
+ const [[changeTracking, rowversion] = []] = await query(this.#pool.request(), this.#location, 'SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), CONVERT(varchar(20), CAST(@@DBTS AS bigint))');
156
156
  return Object.freeze({
157
157
  changeTracking: cellNullableText(changeTracking, 'change tracking version'),
158
158
  rowversion: cellText(rowversion, '@@DBTS'),
@@ -165,7 +165,7 @@ export class SqlServerSession {
165
165
  // statistics has no count, so its value never moves. A login that can read
166
166
  // a table can read its counts.
167
167
  async statistics() {
168
- const rows = await query(this.#pool.request(), statisticsQuery);
168
+ const rows = await query(this.#pool.request(), this.#location, statisticsQuery);
169
169
  return new Map(rows.map(([table, counts]) => [
170
170
  cellText(table, 'table'),
171
171
  cellText(counts, 'statistics'),
@@ -176,7 +176,7 @@ export class SqlServerSession {
176
176
  // also in a table whose statistics SQL Server has not built, but an update
177
177
  // does not. A login that can read a table can read its count.
178
178
  async rowCounts() {
179
- const rows = await query(this.#pool.request(), rowCountsQuery);
179
+ const rows = await query(this.#pool.request(), this.#location, rowCountsQuery);
180
180
  return new Map(rows.map(([table, count]) => [
181
181
  cellText(table, 'table'),
182
182
  cellText(count, 'row count'),
@@ -188,7 +188,7 @@ export class SqlServerSession {
188
188
  // so the value of each table is then empty. A login needs VIEW SERVER
189
189
  // STATE to read them (VIEW DATABASE STATE on Azure SQL Database).
190
190
  async lastWrites() {
191
- const rows = await query(this.#pool.request(), lastWritesQuery).catch((error) => {
191
+ const rows = await query(this.#pool.request(), this.#location, lastWritesQuery).catch((error) => {
192
192
  // 297: the login may not read the state of the server or database.
193
193
  if (error instanceof sql.RequestError && error.number === 297)
194
194
  throw new SqlServerPermissionError('VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL Database)', error);
@@ -203,7 +203,7 @@ export class SqlServerSession {
203
203
  // before the rows, so a change made during the read is read again, never
204
204
  // missed.
205
205
  async changeTrackingPosition(table) {
206
- const [[version, generation] = []] = await query(this.#pool.request(), `SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), ${generationQuery}`, [table.quoted]);
206
+ const [[version, generation] = []] = await query(this.#pool.request(), this.#location, `SELECT CONVERT(varchar(20), CHANGE_TRACKING_CURRENT_VERSION()), ${generationQuery}`, [table.quoted]);
207
207
  if (version === null || generation === null)
208
208
  throw new TypeError(`Change Tracking is off for ${table.quoted} in ${this.#location}`);
209
209
  return Object.freeze({
@@ -214,13 +214,13 @@ export class SqlServerSession {
214
214
  // Rows below this rowversion are committed; one at or above it may belong
215
215
  // to a transaction still open, so a read stops short of it.
216
216
  async minActiveRowversion() {
217
- const [[value] = []] = await query(this.#pool.request(), 'SELECT CONVERT(varchar(20), CAST(MIN_ACTIVE_ROWVERSION() AS bigint))');
217
+ const [[value] = []] = await query(this.#pool.request(), this.#location, 'SELECT CONVERT(varchar(20), CAST(MIN_ACTIVE_ROWVERSION() AS bigint))');
218
218
  return cellText(value, 'MIN_ACTIVE_ROWVERSION');
219
219
  }
220
220
  // Up to limit rows in primary key order, after the given position. The
221
221
  // whole key compares as a tuple, each part cast to its column's type, so
222
222
  // pages neither skip nor repeat rows that share a leading key value.
223
- async *page(table, after, limit) {
223
+ async *page(table, after, limit, read) {
224
224
  if (!table.pageable)
225
225
  throw new TypeError(`${table.quoted} has no primary key a read can page by`);
226
226
  const keys = table.primaryKey;
@@ -236,20 +236,20 @@ export class SqlServerSession {
236
236
  `${key(index)} > ${cast(index)}`,
237
237
  ].join(' AND ')})`)
238
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 ?? []);
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 ?? [], read);
240
240
  }
241
241
  // Up to limit rows whose rowversion is above `above` and below `below`, in
242
242
  // rowversion order.
243
- async *rowversions(table, above, below, limit) {
243
+ async *rowversions(table, above, below, limit, read) {
244
244
  const column = table.rowversion;
245
245
  if (column === undefined)
246
246
  throw new TypeError(`${table.quoted} has no rowversion column`);
247
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]);
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], read);
249
249
  }
250
250
  // Every row, in no particular order.
251
- async *rows(table) {
252
- yield* this.#rows(table, `SELECT ${selection(table)} FROM ${table.quoted} AS t`, []);
251
+ async *rows(table, read) {
252
+ yield* this.#rows(table, `SELECT ${selection(table)} FROM ${table.quoted} AS t`, [], read);
253
253
  }
254
254
  // What changed in the table since a position in its change history: each
255
255
  // changed key once, with its row as it is now, or as a deletion. A position
@@ -259,7 +259,8 @@ export class SqlServerSession {
259
259
  // first, so a change made during the read is read again next time, and the
260
260
  // check runs again after the read in case cleanup removed history
261
261
  // meanwhile.
262
- async changes(table, since) {
262
+ async changes(table, since, read) {
263
+ const location = this.#location;
263
264
  if (table.primaryKey.length === 0)
264
265
  throw new TypeError(`${table.quoted} has no primary key to track changes by`);
265
266
  const transaction = this.#snapshot
@@ -277,7 +278,7 @@ export class SqlServerSession {
277
278
  ? this.#pool.request()
278
279
  : new sql.Request(transaction);
279
280
  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
+ const [[current, minimum, viewable, generation, begin] = []] = await query(request(), location, `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
282
  // Without the grant SQL Server still reports a minimum valid version
282
283
  // (0), so the grant itself is checked first.
283
284
  if (viewable !== 1)
@@ -306,7 +307,7 @@ SELECT ${present}, ${changed.join(', ')}, ${selection(table)}
306
307
  FROM CHANGETABLE(CHANGES ${table.quoted}, @since) AS ct
307
308
  LEFT JOIN ${table.quoted} AS t ON ${keys.map((column) => `t.${column.quoted} = ct.${column.quoted}`).join(' AND ')}`;
308
309
  const changes = async function* () {
309
- for await (const values of stream(request(), text, [since.version])) {
310
+ for await (const values of stream(request(), text, [since.version], read, location)) {
310
311
  const [exists] = values;
311
312
  const key = decode(keys, values.slice(1, 1 + changed.length));
312
313
  yield exists === 1
@@ -331,8 +332,8 @@ LEFT JOIN ${table.quoted} AS t ON ${keys.map((column) => `t.${column.quoted} = c
331
332
  throw error;
332
333
  }
333
334
  }
334
- async *#rows(table, text, parameters) {
335
- for await (const values of stream(this.#pool.request(), text, parameters))
335
+ async *#rows(table, text, parameters, read) {
336
+ for await (const values of stream(this.#pool.request(), text, parameters, read, this.#location))
336
337
  yield new SqlServerRow(table, decode(table.columns, values));
337
338
  }
338
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/sdk-microsoft-sql-server",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Reads one SQL Server database: discovery, exact reads, Change Tracking and change signals. It knows nothing of elt.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {