@eltjs/source-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.
- package/LICENSE +202 -0
- package/README.md +216 -2
- package/dist/change-tracking-stream.d.ts +11 -0
- package/dist/change-tracking-stream.js +149 -0
- package/dist/full-refresh-stream.d.ts +12 -0
- package/dist/full-refresh-stream.js +25 -0
- package/dist/rowversion-stream.d.ts +11 -0
- package/dist/rowversion-stream.js +70 -0
- package/dist/snapshot-stream.d.ts +12 -0
- package/dist/snapshot-stream.js +38 -0
- package/dist/sql-server-copies.d.ts +10 -0
- package/dist/sql-server-copies.js +25 -0
- package/dist/sql-server-fields.d.ts +11 -0
- package/dist/sql-server-fields.js +172 -0
- package/dist/sql-server-source.d.ts +32 -0
- package/dist/sql-server-source.js +113 -0
- package/dist/sql-server-stream.d.ts +27 -0
- package/dist/sql-server-stream.js +65 -0
- package/dist/watch-gauges.d.ts +13 -0
- package/dist/watch-gauges.js +132 -0
- package/dist/whole-table-gauges.d.ts +10 -0
- package/dist/whole-table-gauges.js +31 -0
- package/package.json +59 -4
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SqlServerStream, pageSize } from './sql-server-stream.js';
|
|
2
|
+
function parse(state) {
|
|
3
|
+
if (state === null)
|
|
4
|
+
return null;
|
|
5
|
+
if (typeof state === 'object' &&
|
|
6
|
+
'above' in state &&
|
|
7
|
+
typeof state.above === 'string' &&
|
|
8
|
+
'fields' in state &&
|
|
9
|
+
Array.isArray(state.fields) &&
|
|
10
|
+
state.fields.every((field) => typeof field === 'string'))
|
|
11
|
+
return { above: state.above, fields: state.fields };
|
|
12
|
+
throw new TypeError(`Unknown rowversion state ${JSON.stringify(state)}`);
|
|
13
|
+
}
|
|
14
|
+
// A keyed table with a rowversion column and no Change Tracking the login
|
|
15
|
+
// can read: each read takes the rows inserted or updated since the last,
|
|
16
|
+
// in rowversion order, up to the oldest write still open. A deleted row is
|
|
17
|
+
// never seen, so it stays loaded. Adding a column bumps no rowversion, so a
|
|
18
|
+
// table that gained one is read again from its first row.
|
|
19
|
+
export class RowversionStream extends SqlServerStream {
|
|
20
|
+
constructor(table) {
|
|
21
|
+
super(table, {
|
|
22
|
+
supportedSyncModes: ['full_refresh', 'incremental'],
|
|
23
|
+
sourceDefinedCursor: true,
|
|
24
|
+
strategy: `Read by its rowversion column ${table.rowversion?.name}: inserts and updates apply; deletions are not seen.`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
gauges(watch) {
|
|
28
|
+
return [watch.rowversion];
|
|
29
|
+
}
|
|
30
|
+
coverage() {
|
|
31
|
+
return {
|
|
32
|
+
description: `Every row of ${this.table.quoted} the login can read, inserted or updated since the last read`,
|
|
33
|
+
selection: { table: this.table.quoted },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async *extract(configuration, state, session) {
|
|
37
|
+
if (configuration.syncMode === 'full_refresh') {
|
|
38
|
+
yield* this.everyRow(session);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const column = this.#column();
|
|
42
|
+
const below = await session.minActiveRowversion();
|
|
43
|
+
const saved = parse(state);
|
|
44
|
+
let above = saved === null || this.gained(saved.fields) ? '0' : saved.above;
|
|
45
|
+
const fields = this.fieldNames;
|
|
46
|
+
for (;;) {
|
|
47
|
+
let count = 0;
|
|
48
|
+
for await (const row of session.rowversions(this.table, above, below, pageSize)) {
|
|
49
|
+
yield this.message(row);
|
|
50
|
+
above = String(row.value(column));
|
|
51
|
+
count += 1;
|
|
52
|
+
}
|
|
53
|
+
if (count === 0)
|
|
54
|
+
return;
|
|
55
|
+
yield {
|
|
56
|
+
type: 'STATE',
|
|
57
|
+
stream: this.stream.name,
|
|
58
|
+
state: { above, fields },
|
|
59
|
+
};
|
|
60
|
+
if (count < pageSize)
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
#column() {
|
|
65
|
+
const column = this.table.rowversion;
|
|
66
|
+
if (column === undefined)
|
|
67
|
+
throw new TypeError(`${this.table.quoted} has no rowversion column`);
|
|
68
|
+
return column;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type CopyConfiguration, type ExtractionCoverage, type SourceMessage } from '@eltjs/elt';
|
|
2
|
+
import type { SqlServerSession, SqlServerTable } from '@eltjs/sdk-microsoft-sql-server';
|
|
3
|
+
import { SqlServerStream } from './sql-server-stream.ts';
|
|
4
|
+
import type { WatchGauges } from './watch-gauges.ts';
|
|
5
|
+
import type { WholeTableGauges } from './whole-table-gauges.ts';
|
|
6
|
+
export declare class SnapshotStream extends SqlServerStream {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(table: SqlServerTable, whole: WholeTableGauges);
|
|
9
|
+
gauges(watch: WatchGauges): readonly import("@eltjs/elt").Gauge<import("@zukhruf/async").Value>[];
|
|
10
|
+
coverage(): ExtractionCoverage;
|
|
11
|
+
extract(configuration: CopyConfiguration, state: unknown, session: SqlServerSession, signal: AbortSignal | undefined): AsyncIterable<SourceMessage>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ClosedWorld } from '@zukhruf/snapshot-differential';
|
|
2
|
+
import { diffSnapshot, } from '@eltjs/elt';
|
|
3
|
+
import { SqlServerStream } from './sql-server-stream.js';
|
|
4
|
+
// A keyed table with no Change Tracking or rowversion the login can read:
|
|
5
|
+
// every read takes every row, and an incremental copy compares the read with
|
|
6
|
+
// the snapshot in its checkpoint, so it writes only the rows that are new or
|
|
7
|
+
// changed and deletes the rows that are gone. The read lists every row, so a
|
|
8
|
+
// key it does not list was deleted.
|
|
9
|
+
export class SnapshotStream extends SqlServerStream {
|
|
10
|
+
#whole;
|
|
11
|
+
constructor(table, whole) {
|
|
12
|
+
const hint = table.changeTracking === 'denied'
|
|
13
|
+
? ` Granting the login VIEW CHANGE TRACKING ON ${table.quoted} would read only changes and deletions.`
|
|
14
|
+
: '';
|
|
15
|
+
super(table, {
|
|
16
|
+
supportedSyncModes: ['full_refresh', 'incremental'],
|
|
17
|
+
sourceDefinedCursor: true,
|
|
18
|
+
emitsDeletes: true,
|
|
19
|
+
strategy: `Read in full on every sync; new and changed rows and deletions apply.${hint}${whole.hint}`,
|
|
20
|
+
});
|
|
21
|
+
this.#whole = whole;
|
|
22
|
+
}
|
|
23
|
+
gauges(watch) {
|
|
24
|
+
return this.#whole.gauges(this.table, watch);
|
|
25
|
+
}
|
|
26
|
+
coverage() {
|
|
27
|
+
return {
|
|
28
|
+
description: `Every row of ${this.table.quoted} the login can read`,
|
|
29
|
+
selection: { table: this.table.quoted },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
extract(configuration, state, session, signal) {
|
|
33
|
+
return diffSnapshot(configuration, this.records(session), state, {
|
|
34
|
+
policy: new ClosedWorld(),
|
|
35
|
+
signal,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Copy, type Target } from '@eltjs/elt';
|
|
2
|
+
import type { SqlServerSource } from './sql-server-source.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Every table as a copy into `target(stream)`: an incremental copy that
|
|
5
|
+
* applies changes by key for a stream that reads changes, and a full
|
|
6
|
+
* refresh that replaces the target for one that reads every row. Each copy
|
|
7
|
+
* is named after its source and stream, so its checkpoint follows the table
|
|
8
|
+
* and two databases that hold a table of one name share a pipeline.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sqlServerCopies<T extends Target>(source: SqlServerSource, target: (stream: string) => T): Copy<T>[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Copy } from '@eltjs/elt';
|
|
2
|
+
/**
|
|
3
|
+
* Every table as a copy into `target(stream)`: an incremental copy that
|
|
4
|
+
* applies changes by key for a stream that reads changes, and a full
|
|
5
|
+
* refresh that replaces the target for one that reads every row. Each copy
|
|
6
|
+
* is named after its source and stream, so its checkpoint follows the table
|
|
7
|
+
* and two databases that hold a table of one name share a pipeline.
|
|
8
|
+
*/
|
|
9
|
+
export function sqlServerCopies(source, target) {
|
|
10
|
+
return source.streams.map((stream) => {
|
|
11
|
+
const mode = stream.sourceDefinedCursor
|
|
12
|
+
? {
|
|
13
|
+
syncMode: 'incremental',
|
|
14
|
+
destinationSyncMode: 'append_dedup',
|
|
15
|
+
}
|
|
16
|
+
: {
|
|
17
|
+
syncMode: 'full_refresh',
|
|
18
|
+
destinationSyncMode: 'overwrite',
|
|
19
|
+
};
|
|
20
|
+
return new Copy(stream, target(stream.name), {
|
|
21
|
+
id: `${source.identity}/${stream.name}`,
|
|
22
|
+
...mode,
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { KeyValue, Properties } from '@eltjs/elt';
|
|
2
|
+
import { type SqlServerRow, type SqlServerTable, type SqlServerValue } from '@eltjs/sdk-microsoft-sql-server';
|
|
3
|
+
export declare function sentence(text: string): string;
|
|
4
|
+
export declare class TableFields {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(table: SqlServerTable);
|
|
7
|
+
get properties(): Properties;
|
|
8
|
+
get primaryKey(): string[];
|
|
9
|
+
record(row: SqlServerRow): Record<string, unknown>;
|
|
10
|
+
key(values: readonly SqlServerValue[]): Record<string, KeyValue>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { OffsetTimestamp, VariantValue, } from '@eltjs/sdk-microsoft-sql-server';
|
|
2
|
+
// The JSON Schema of a column's values, before nullability and description.
|
|
3
|
+
function scalar(column) {
|
|
4
|
+
const { value } = column.codec;
|
|
5
|
+
switch (value.kind) {
|
|
6
|
+
case 'text':
|
|
7
|
+
case 'spatial':
|
|
8
|
+
case 'variant':
|
|
9
|
+
return { type: 'string' };
|
|
10
|
+
case 'integer':
|
|
11
|
+
return { type: 'integer' };
|
|
12
|
+
case 'int64':
|
|
13
|
+
return { type: 'string', format: 'int64' };
|
|
14
|
+
case 'boolean':
|
|
15
|
+
return { type: 'boolean' };
|
|
16
|
+
case 'number':
|
|
17
|
+
return { type: 'number' };
|
|
18
|
+
case 'decimal':
|
|
19
|
+
return {
|
|
20
|
+
type: 'string',
|
|
21
|
+
format: 'decimal',
|
|
22
|
+
precision: value.precision,
|
|
23
|
+
scale: value.scale,
|
|
24
|
+
};
|
|
25
|
+
case 'date':
|
|
26
|
+
return { type: 'string', format: 'date' };
|
|
27
|
+
case 'local-timestamp':
|
|
28
|
+
return {
|
|
29
|
+
type: 'string',
|
|
30
|
+
format: 'date-time-local',
|
|
31
|
+
precision: value.precision,
|
|
32
|
+
};
|
|
33
|
+
case 'timestamp':
|
|
34
|
+
return {
|
|
35
|
+
type: 'string',
|
|
36
|
+
format: 'date-time',
|
|
37
|
+
precision: value.precision,
|
|
38
|
+
};
|
|
39
|
+
case 'local-time':
|
|
40
|
+
return {
|
|
41
|
+
type: 'string',
|
|
42
|
+
format: 'time-local',
|
|
43
|
+
precision: value.precision,
|
|
44
|
+
};
|
|
45
|
+
case 'bytes':
|
|
46
|
+
return { type: 'string', contentEncoding: 'base64' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// A description from SQL Server ends its sentence, so the notes after it
|
|
50
|
+
// read as sentences of their own.
|
|
51
|
+
export function sentence(text) {
|
|
52
|
+
return /[.!?]$/.test(text.trimEnd()) ? text.trimEnd() : `${text.trimEnd()}.`;
|
|
53
|
+
}
|
|
54
|
+
function described(column, sibling) {
|
|
55
|
+
const type = column.type === 'timestamp'
|
|
56
|
+
? 'rowversion, which SQL Server raises on every insert and update of the row'
|
|
57
|
+
: column.type;
|
|
58
|
+
const own = column.description === null
|
|
59
|
+
? `SQL Server column ${column.name} (${type}).`
|
|
60
|
+
: sentence(column.description);
|
|
61
|
+
const notes = [
|
|
62
|
+
column.codec.value.kind === 'timestamp'
|
|
63
|
+
? `The UTC instant; ${sibling} holds the offset it was written with.`
|
|
64
|
+
: undefined,
|
|
65
|
+
column.codec.value.kind === 'spatial'
|
|
66
|
+
? 'Extended well-known text: SRID=<id>; then the shape with its Z and M.'
|
|
67
|
+
: undefined,
|
|
68
|
+
column.masked
|
|
69
|
+
? 'Dynamic data masking applies: a login without UNMASK reads masked values.'
|
|
70
|
+
: undefined,
|
|
71
|
+
];
|
|
72
|
+
return [own, ...notes.filter((note) => note !== undefined)].join(' ');
|
|
73
|
+
}
|
|
74
|
+
function field(schema, nullable, description) {
|
|
75
|
+
return {
|
|
76
|
+
...schema,
|
|
77
|
+
type: nullable ? [schema.type, 'null'] : schema.type,
|
|
78
|
+
description,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// What a column adds beside itself: the offset of a datetimeoffset, and the
|
|
82
|
+
// base type of a sql_variant.
|
|
83
|
+
function suffix(column) {
|
|
84
|
+
switch (column.codec.value.kind) {
|
|
85
|
+
case 'timestamp':
|
|
86
|
+
return '_offset';
|
|
87
|
+
case 'variant':
|
|
88
|
+
return '_type';
|
|
89
|
+
default:
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A table's record fields: one per readable column, plus the field a
|
|
94
|
+
// datetimeoffset or sql_variant column adds beside itself, named once for
|
|
95
|
+
// the table. A column may already hold <column>_offset, so the sibling takes
|
|
96
|
+
// the next free name: <column>_offset_1, then _2. Names compare without case,
|
|
97
|
+
// as SQL Server's default collation compares them.
|
|
98
|
+
export class TableFields {
|
|
99
|
+
#table;
|
|
100
|
+
#siblings;
|
|
101
|
+
constructor(table) {
|
|
102
|
+
this.#table = table;
|
|
103
|
+
const taken = new Set(table.columns.map(({ name }) => name.toLowerCase()));
|
|
104
|
+
const siblings = new Map();
|
|
105
|
+
for (const column of table.columns) {
|
|
106
|
+
const added = suffix(column);
|
|
107
|
+
if (added === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
const base = `${column.name}${added}`;
|
|
110
|
+
let name = base;
|
|
111
|
+
for (let next = 1; taken.has(name.toLowerCase()); next += 1)
|
|
112
|
+
name = `${base}_${next}`;
|
|
113
|
+
taken.add(name.toLowerCase());
|
|
114
|
+
siblings.set(column, name);
|
|
115
|
+
}
|
|
116
|
+
this.#siblings = siblings;
|
|
117
|
+
Object.freeze(this);
|
|
118
|
+
}
|
|
119
|
+
get properties() {
|
|
120
|
+
const fields = {};
|
|
121
|
+
for (const column of this.#table.columns) {
|
|
122
|
+
const sibling = this.#siblings.get(column);
|
|
123
|
+
fields[column.name] = field(scalar(column), column.nullable, described(column, sibling));
|
|
124
|
+
if (sibling === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
fields[sibling] =
|
|
127
|
+
column.codec.value.kind === 'timestamp'
|
|
128
|
+
? field({ type: 'integer' }, column.nullable, `The offset from UTC, in minutes, ${column.name} was written with.`)
|
|
129
|
+
: field({ type: 'string' }, column.nullable, `The SQL Server type of the value ${column.name} holds.`);
|
|
130
|
+
}
|
|
131
|
+
return fields;
|
|
132
|
+
}
|
|
133
|
+
// The stream's key: the table's primary key, with the base type of a
|
|
134
|
+
// sql_variant key column, since its text alone can repeat across types.
|
|
135
|
+
get primaryKey() {
|
|
136
|
+
return this.#table.primaryKey.flatMap((column) => {
|
|
137
|
+
const type = column.codec.value.kind === 'variant'
|
|
138
|
+
? this.#siblings.get(column)
|
|
139
|
+
: undefined;
|
|
140
|
+
return type === undefined ? [column.name] : [column.name, type];
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
record(row) {
|
|
144
|
+
return Object.assign({}, ...this.#table.columns.map((column, index) => this.#plain(column, row.values[index] ?? null)));
|
|
145
|
+
}
|
|
146
|
+
// A deleted row's key fields, from its primary key values in key order.
|
|
147
|
+
key(values) {
|
|
148
|
+
const fields = {};
|
|
149
|
+
for (const [index, column] of this.#table.primaryKey.entries()) {
|
|
150
|
+
const value = values[index] ?? null;
|
|
151
|
+
for (const [name, part] of Object.entries(this.#plain(column, value))) {
|
|
152
|
+
if (typeof part !== 'string' &&
|
|
153
|
+
typeof part !== 'number' &&
|
|
154
|
+
typeof part !== 'boolean')
|
|
155
|
+
throw new TypeError(`${this.#table.quoted} key ${column.name} has no value`);
|
|
156
|
+
if (name === column.name || column.codec.value.kind === 'variant')
|
|
157
|
+
fields[name] = part;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return fields;
|
|
161
|
+
}
|
|
162
|
+
#plain(column, value) {
|
|
163
|
+
const sibling = this.#siblings.get(column);
|
|
164
|
+
if (sibling === undefined)
|
|
165
|
+
return { [column.name]: value };
|
|
166
|
+
if (value instanceof OffsetTimestamp)
|
|
167
|
+
return { [column.name]: value.instant, [sibling]: value.offset };
|
|
168
|
+
if (value instanceof VariantValue)
|
|
169
|
+
return { [column.name]: value.text, [sibling]: value.type };
|
|
170
|
+
return { [column.name]: null, [sibling]: null };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Gauge, Group } from '@zukhruf/async';
|
|
2
|
+
import { Catalog, type CopyConfiguration, type ExtractionCoverage, type Partition, Source, type SourceMessage, type Stream } from '@eltjs/elt';
|
|
3
|
+
import type { SqlServerDatabase, SqlServerSession } from '@eltjs/sdk-microsoft-sql-server';
|
|
4
|
+
import { SqlServerLoginError, SqlServerPermissionError } from '@eltjs/sdk-microsoft-sql-server';
|
|
5
|
+
import { WatchGauges } from './watch-gauges.ts';
|
|
6
|
+
declare class StreamGroup extends Group implements AsyncDisposable {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(members: readonly Gauge[], gauges: WatchGauges);
|
|
9
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare class SqlServerSource extends Source<SqlServerSession> {
|
|
12
|
+
#private;
|
|
13
|
+
readonly identity: string;
|
|
14
|
+
protected readonly catalog: Catalog;
|
|
15
|
+
static discover(database: SqlServerDatabase, options?: {
|
|
16
|
+
readonly schemas?: readonly string[];
|
|
17
|
+
}): Promise<SqlServerSource>;
|
|
18
|
+
private constructor();
|
|
19
|
+
get streams(): readonly Stream[];
|
|
20
|
+
protected open(): Promise<SqlServerSession>;
|
|
21
|
+
protected readonly configErrors: (typeof SqlServerLoginError | typeof SqlServerPermissionError)[];
|
|
22
|
+
coverage(stream: Stream): ExtractionCoverage;
|
|
23
|
+
currentAsOf(): Promise<null>;
|
|
24
|
+
changeSignals(streams: readonly Stream[]): {
|
|
25
|
+
gauge: StreamGroup;
|
|
26
|
+
streams: Stream[];
|
|
27
|
+
interval: number;
|
|
28
|
+
timeout: number;
|
|
29
|
+
}[];
|
|
30
|
+
protected extract(configuration: CopyConfiguration, state: unknown, _partition: Partition | null, session: SqlServerSession, signal: AbortSignal | undefined): AsyncIterable<SourceMessage>;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Group } from '@zukhruf/async';
|
|
2
|
+
import { Catalog, Source, } from '@eltjs/elt';
|
|
3
|
+
import { SqlServerLoginError, SqlServerPermissionError, } from '@eltjs/sdk-microsoft-sql-server';
|
|
4
|
+
import { ChangeTrackingStream } from './change-tracking-stream.js';
|
|
5
|
+
import { FullRefreshStream } from './full-refresh-stream.js';
|
|
6
|
+
import { RowversionStream } from './rowversion-stream.js';
|
|
7
|
+
import { SnapshotStream } from './snapshot-stream.js';
|
|
8
|
+
import { WatchGauges } from './watch-gauges.js';
|
|
9
|
+
import { WholeTableGauges } from './whole-table-gauges.js';
|
|
10
|
+
// How often a watch asks the database whether anything changed.
|
|
11
|
+
const pollIntervalMs = 30_000;
|
|
12
|
+
// How long the queries of one check can take. Each query took 160 to 300 ms
|
|
13
|
+
// on Azure SQL Database, and a check runs at most four at once, one on each
|
|
14
|
+
// connection of the session. A check that takes a third of the interval is
|
|
15
|
+
// stuck: its gauges fail, as a system failure, and the next check reads them
|
|
16
|
+
// again.
|
|
17
|
+
const checkTimeoutMs = 10_000;
|
|
18
|
+
// The gauges of one stream: a check wakes the stream when one changed. The
|
|
19
|
+
// streams of a watch share its session, which the first group that the
|
|
20
|
+
// pipeline disposes closes.
|
|
21
|
+
class StreamGroup extends Group {
|
|
22
|
+
#gauges;
|
|
23
|
+
constructor(members, gauges) {
|
|
24
|
+
super(members);
|
|
25
|
+
this.#gauges = gauges;
|
|
26
|
+
}
|
|
27
|
+
[Symbol.asyncDispose]() {
|
|
28
|
+
return this.#gauges[Symbol.asyncDispose]();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Change Tracking when the login can read it, else the rowversion, else a
|
|
32
|
+
// snapshot of the whole table, all by key; a table with no key in full.
|
|
33
|
+
function streamFor(table, whole) {
|
|
34
|
+
if (table.primaryKey.length === 0)
|
|
35
|
+
return new FullRefreshStream(table, whole);
|
|
36
|
+
if (table.changeTracking === 'readable')
|
|
37
|
+
return new ChangeTrackingStream(table);
|
|
38
|
+
if (table.rowversion !== undefined)
|
|
39
|
+
return new RowversionStream(table);
|
|
40
|
+
return new SnapshotStream(table, whole);
|
|
41
|
+
}
|
|
42
|
+
// Every table one SQL Server database holds that the login can read, one
|
|
43
|
+
// stream each, named <schema>.<table>. The streams are the database's own
|
|
44
|
+
// tables, so a source is discovered, and a host discovers it again on every
|
|
45
|
+
// run: a table whose columns changed arrives as a changed stream, which
|
|
46
|
+
// starts its copy over.
|
|
47
|
+
export class SqlServerSource extends Source {
|
|
48
|
+
identity;
|
|
49
|
+
catalog;
|
|
50
|
+
#database;
|
|
51
|
+
#streams;
|
|
52
|
+
static async discover(database, options = {}) {
|
|
53
|
+
await using session = await database.open();
|
|
54
|
+
// A table whose every column is denied has nothing to read.
|
|
55
|
+
const tables = (await session.tables()).filter(({ schema, columns }) => columns.length > 0 &&
|
|
56
|
+
(options.schemas === undefined || options.schemas.includes(schema)));
|
|
57
|
+
// A schema that keeps nothing is misspelled, cased unlike the database, or
|
|
58
|
+
// not granted; loading less in silence would hide it.
|
|
59
|
+
for (const schema of options.schemas ?? [])
|
|
60
|
+
if (!tables.some((table) => table.schema === schema))
|
|
61
|
+
throw new TypeError(`The schemas to keep name ${schema}, which holds no table this login can read in ${database.location}`);
|
|
62
|
+
const whole = await WholeTableGauges.discover(session);
|
|
63
|
+
return new SqlServerSource(database, tables.map((table) => streamFor(table, whole)));
|
|
64
|
+
}
|
|
65
|
+
constructor(database, streams) {
|
|
66
|
+
super();
|
|
67
|
+
this.identity = `sql-server:${database.name}`;
|
|
68
|
+
this.#database = database;
|
|
69
|
+
this.#streams = new Map(streams.map((stream) => [stream.stream.name, stream]));
|
|
70
|
+
this.catalog = new Catalog(streams.map(({ stream }) => stream));
|
|
71
|
+
Object.freeze(this);
|
|
72
|
+
}
|
|
73
|
+
// The streams the database's tables became, by name.
|
|
74
|
+
get streams() {
|
|
75
|
+
return this.catalog.streams;
|
|
76
|
+
}
|
|
77
|
+
open() {
|
|
78
|
+
return this.#database.open();
|
|
79
|
+
}
|
|
80
|
+
// A server that cannot be reached now is a system failure: a later
|
|
81
|
+
// attempt, or the next check of a watch, can succeed.
|
|
82
|
+
configErrors = [
|
|
83
|
+
SqlServerLoginError,
|
|
84
|
+
SqlServerPermissionError,
|
|
85
|
+
];
|
|
86
|
+
coverage(stream) {
|
|
87
|
+
return this.#reader(stream).coverage();
|
|
88
|
+
}
|
|
89
|
+
async currentAsOf() {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Checks the gauges of the streams every 30 s. A refused login fails
|
|
93
|
+
// every stream as config, and the pipeline reads its gauges no more in
|
|
94
|
+
// this watch.
|
|
95
|
+
changeSignals(streams) {
|
|
96
|
+
const gauges = new WatchGauges(this.#database);
|
|
97
|
+
return streams.map((stream) => ({
|
|
98
|
+
gauge: new StreamGroup(this.#reader(stream).gauges(gauges), gauges),
|
|
99
|
+
streams: [stream],
|
|
100
|
+
interval: pollIntervalMs,
|
|
101
|
+
timeout: checkTimeoutMs,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
extract(configuration, state, _partition, session, signal) {
|
|
105
|
+
return this.#reader(configuration.stream).extract(configuration, state, session, signal);
|
|
106
|
+
}
|
|
107
|
+
#reader(stream) {
|
|
108
|
+
const reader = this.#streams.get(stream.name);
|
|
109
|
+
if (reader === undefined)
|
|
110
|
+
throw new TypeError(`SQL Server has no stream ${stream.name}`);
|
|
111
|
+
return reader;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Gauge } from '@zukhruf/async';
|
|
2
|
+
import { type CopyConfiguration, type ExtractionCoverage, type SourceMessage, Stream, type SyncMode } from '@eltjs/elt';
|
|
3
|
+
import type { SqlServerRow, SqlServerSession, SqlServerTable } from '@eltjs/sdk-microsoft-sql-server';
|
|
4
|
+
import { TableFields } from './sql-server-fields.ts';
|
|
5
|
+
import type { WatchGauges } from './watch-gauges.ts';
|
|
6
|
+
export declare const pageSize = 10000;
|
|
7
|
+
type SyncTraits = {
|
|
8
|
+
readonly supportedSyncModes: readonly SyncMode[];
|
|
9
|
+
readonly sourceDefinedCursor?: true;
|
|
10
|
+
readonly emitsDeletes?: true;
|
|
11
|
+
readonly strategy: string;
|
|
12
|
+
};
|
|
13
|
+
export declare abstract class SqlServerStream {
|
|
14
|
+
readonly table: SqlServerTable;
|
|
15
|
+
readonly fields: TableFields;
|
|
16
|
+
readonly stream: Stream;
|
|
17
|
+
constructor(table: SqlServerTable, traits: SyncTraits);
|
|
18
|
+
abstract coverage(): ExtractionCoverage;
|
|
19
|
+
abstract gauges(watch: WatchGauges): readonly Gauge[];
|
|
20
|
+
protected get fieldNames(): readonly string[];
|
|
21
|
+
protected gained(read: readonly string[]): boolean;
|
|
22
|
+
abstract extract(configuration: CopyConfiguration, state: unknown, session: SqlServerSession, signal: AbortSignal | undefined): AsyncIterable<SourceMessage>;
|
|
23
|
+
protected message(row: SqlServerRow): SourceMessage;
|
|
24
|
+
protected records(session: SqlServerSession): AsyncGenerator<Record<string, unknown>>;
|
|
25
|
+
protected everyRow(session: SqlServerSession): AsyncGenerator<SourceMessage>;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Stream, validateRecords, } from '@eltjs/elt';
|
|
2
|
+
import { TableFields, sentence } from './sql-server-fields.js';
|
|
3
|
+
// Rows per page of a resumable read: each page ends in a checkpoint, so a
|
|
4
|
+
// read that fails resumes after the last page it committed.
|
|
5
|
+
export const pageSize = 10_000;
|
|
6
|
+
// One table as a stream. Each way of reading a table is its own subclass:
|
|
7
|
+
// the base names the stream after the table, describes its columns, and
|
|
8
|
+
// turns rows into records; a subclass declares how it syncs and reads.
|
|
9
|
+
export class SqlServerStream {
|
|
10
|
+
table;
|
|
11
|
+
fields;
|
|
12
|
+
stream;
|
|
13
|
+
constructor(table, traits) {
|
|
14
|
+
this.table = table;
|
|
15
|
+
this.fields = new TableFields(table);
|
|
16
|
+
const { properties } = this.fields;
|
|
17
|
+
this.stream = new Stream({
|
|
18
|
+
name: `${table.schema}.${table.name}`,
|
|
19
|
+
jsonSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
description: [
|
|
22
|
+
table.description === null
|
|
23
|
+
? `SQL Server table ${table.quoted}.`
|
|
24
|
+
: sentence(table.description),
|
|
25
|
+
traits.strategy,
|
|
26
|
+
table.unreadable.length > 0
|
|
27
|
+
? `The login cannot read ${table.unreadable.join(', ')}, which are left out.`
|
|
28
|
+
: undefined,
|
|
29
|
+
]
|
|
30
|
+
.filter((part) => part !== undefined)
|
|
31
|
+
.join(' '),
|
|
32
|
+
properties,
|
|
33
|
+
required: Object.keys(properties),
|
|
34
|
+
},
|
|
35
|
+
primaryKey: this.fields.primaryKey,
|
|
36
|
+
supportedSyncModes: traits.supportedSyncModes,
|
|
37
|
+
sourceDefinedCursor: traits.sourceDefinedCursor,
|
|
38
|
+
emitsDeletes: traits.emitsDeletes,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// The fields a read carries. A cursor's state records them, since a field
|
|
42
|
+
// the table gains later reaches only rows changed after it unless every row
|
|
43
|
+
// is read again: elt keeps a stream's checkpoint and rows through a change
|
|
44
|
+
// of its fields, and leaves filling them in to the source.
|
|
45
|
+
get fieldNames() {
|
|
46
|
+
return Object.keys(this.fields.properties);
|
|
47
|
+
}
|
|
48
|
+
// Whether this stream carries a field a state was read without.
|
|
49
|
+
gained(read) {
|
|
50
|
+
return this.fieldNames.some((field) => !read.includes(field));
|
|
51
|
+
}
|
|
52
|
+
message(row) {
|
|
53
|
+
const [data] = validateRecords(this.stream, [this.fields.record(row)], 'SQL Server');
|
|
54
|
+
return { stream: this.stream.name, data };
|
|
55
|
+
}
|
|
56
|
+
// Every row as it is now, in one read.
|
|
57
|
+
async *records(session) {
|
|
58
|
+
for await (const row of session.rows(this.table))
|
|
59
|
+
yield* validateRecords(this.stream, [this.fields.record(row)], 'SQL Server');
|
|
60
|
+
}
|
|
61
|
+
async *everyRow(session) {
|
|
62
|
+
for await (const data of this.records(session))
|
|
63
|
+
yield { stream: this.stream.name, data };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Collector, type Gauge, Period } from '@zukhruf/async';
|
|
2
|
+
import type { SqlServerDatabase } from '@eltjs/sdk-microsoft-sql-server';
|
|
3
|
+
export declare class WatchGauges implements AsyncDisposable {
|
|
4
|
+
#private;
|
|
5
|
+
readonly statistics: Collector<string>;
|
|
6
|
+
readonly rowCounts: Collector<string>;
|
|
7
|
+
readonly lastWrites: Collector<string>;
|
|
8
|
+
readonly changeTracking: Gauge;
|
|
9
|
+
readonly rowversion: Gauge;
|
|
10
|
+
readonly hour: Period;
|
|
11
|
+
constructor(database: SqlServerDatabase);
|
|
12
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
13
|
+
}
|