@eltjs/elt 0.0.0-stage → 0.1.1
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 +1153 -2
- package/dist/core/catalog.d.ts +7 -0
- package/dist/core/catalog.js +23 -0
- package/dist/core/change-signal.d.ts +9 -0
- package/dist/core/change-signal.js +4 -0
- package/dist/core/clock.d.ts +6 -0
- package/dist/core/clock.js +1 -0
- package/dist/core/connection.d.ts +20 -0
- package/dist/core/connection.js +31 -0
- package/dist/core/copy-configuration.d.ts +24 -0
- package/dist/core/copy-configuration.js +120 -0
- package/dist/core/copy.d.ts +27 -0
- package/dist/core/copy.js +56 -0
- package/dist/core/deduplication.d.ts +14 -0
- package/dist/core/deduplication.js +96 -0
- package/dist/core/derived-dataset.d.ts +19 -0
- package/dist/core/derived-dataset.js +10 -0
- package/dist/core/derived-refreshes.d.ts +15 -0
- package/dist/core/derived-refreshes.js +120 -0
- package/dist/core/destination.d.ts +22 -0
- package/dist/core/destination.js +17 -0
- package/dist/core/document-parser.d.ts +6 -0
- package/dist/core/document-parser.js +15 -0
- package/dist/core/document-text.d.ts +28 -0
- package/dist/core/document-text.js +72 -0
- package/dist/core/file-content.d.ts +5 -0
- package/dist/core/file-content.js +24 -0
- package/dist/core/file-extension.d.ts +1 -0
- package/dist/core/file-extension.js +9 -0
- package/dist/core/file-read.d.ts +20 -0
- package/dist/core/file-read.js +79 -0
- package/dist/core/file-storage.d.ts +8 -0
- package/dist/core/file-storage.js +4 -0
- package/dist/core/file-transfer.d.ts +12 -0
- package/dist/core/file-transfer.js +65 -0
- package/dist/core/formats/base64-format.d.ts +6 -0
- package/dist/core/formats/base64-format.js +13 -0
- package/dist/core/formats/calendar-date-format.d.ts +6 -0
- package/dist/core/formats/calendar-date-format.js +13 -0
- package/dist/core/formats/decimal-format.d.ts +9 -0
- package/dist/core/formats/decimal-format.js +41 -0
- package/dist/core/formats/declared-format.d.ts +12 -0
- package/dist/core/formats/declared-format.js +48 -0
- package/dist/core/formats/field-format.d.ts +6 -0
- package/dist/core/formats/field-format.js +10 -0
- package/dist/core/formats/int64-format.d.ts +6 -0
- package/dist/core/formats/int64-format.js +16 -0
- package/dist/core/formats/local-time-format.d.ts +6 -0
- package/dist/core/formats/local-time-format.js +10 -0
- package/dist/core/formats/local-timestamp-format.d.ts +6 -0
- package/dist/core/formats/local-timestamp-format.js +10 -0
- package/dist/core/formats/temporal-format.d.ts +7 -0
- package/dist/core/formats/temporal-format.js +23 -0
- package/dist/core/formats/timestamp-format.d.ts +7 -0
- package/dist/core/formats/timestamp-format.js +14 -0
- package/dist/core/identifiers.d.ts +10 -0
- package/dist/core/identifiers.js +51 -0
- package/dist/core/interleave.d.ts +1 -0
- package/dist/core/interleave.js +38 -0
- package/dist/core/partition.d.ts +16 -0
- package/dist/core/partition.js +60 -0
- package/dist/core/pass-channels.d.ts +46 -0
- package/dist/core/pass-channels.js +65 -0
- package/dist/core/pipeline.d.ts +37 -0
- package/dist/core/pipeline.js +546 -0
- package/dist/core/reader-relation.d.ts +6 -0
- package/dist/core/reader-relation.js +11 -0
- package/dist/core/record-validation.d.ts +58 -0
- package/dist/core/record-validation.js +128 -0
- package/dist/core/reload-mode.d.ts +9 -0
- package/dist/core/reload-mode.js +17 -0
- package/dist/core/replication.d.ts +8 -0
- package/dist/core/replication.js +478 -0
- package/dist/core/rule.d.ts +29 -0
- package/dist/core/rule.js +6 -0
- package/dist/core/signal-watch.d.ts +11 -0
- package/dist/core/signal-watch.js +110 -0
- package/dist/core/snapshot.d.ts +17 -0
- package/dist/core/snapshot.js +111 -0
- package/dist/core/source.d.ts +79 -0
- package/dist/core/source.js +227 -0
- package/dist/core/stream-change.d.ts +5 -0
- package/dist/core/stream-change.js +31 -0
- package/dist/core/stream.d.ts +26 -0
- package/dist/core/stream.js +86 -0
- package/dist/core/target-description.d.ts +15 -0
- package/dist/core/target-description.js +73 -0
- package/dist/core/target.d.ts +5 -0
- package/dist/core/target.js +14 -0
- package/dist/core/upkeep.d.ts +4 -0
- package/dist/core/upkeep.js +1 -0
- package/dist/core/writer.d.ts +41 -0
- package/dist/core/writer.js +17 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +36 -0
- package/dist/state/checkpoint-store.d.ts +29 -0
- package/dist/state/checkpoint-store.js +81 -0
- package/dist/state/sync-history-relations.d.ts +72 -0
- package/dist/state/sync-history-relations.js +74 -0
- package/dist/state/sync-history.d.ts +37 -0
- package/dist/state/sync-history.js +80 -0
- package/dist/storage/local-files.d.ts +15 -0
- package/dist/storage/local-files.js +121 -0
- package/package.json +48 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Outcome } from '@zukhruf/async';
|
|
2
|
+
import type { ChangeSignal } from './change-signal.ts';
|
|
3
|
+
import type { PipelineClock } from './clock.ts';
|
|
4
|
+
import type { FailureType } from './source.ts';
|
|
5
|
+
import type { Stream } from './stream.ts';
|
|
6
|
+
export declare function brokenSignals(signals: readonly ChangeSignal[], selected: readonly Stream[]): string | undefined;
|
|
7
|
+
export declare function watchSignals(signals: readonly ChangeSignal[], options: {
|
|
8
|
+
readonly signal: AbortSignal;
|
|
9
|
+
readonly clock: PipelineClock;
|
|
10
|
+
readonly failureType: (error: unknown) => FailureType;
|
|
11
|
+
}): AsyncGenerator<ReadonlyMap<string, Outcome>>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ChangeWatcher, Gauge, } from '@zukhruf/async';
|
|
2
|
+
import { interleave } from './interleave.js';
|
|
3
|
+
// Why a source's change signals break the contract for the selected streams,
|
|
4
|
+
// or undefined when they keep it: each selected stream is in one change
|
|
5
|
+
// signal or more, no change signal names another stream, and the change
|
|
6
|
+
// signals of one stream have one interval and one timeout.
|
|
7
|
+
export function brokenSignals(signals, selected) {
|
|
8
|
+
const names = new Set(selected.map(({ name }) => name));
|
|
9
|
+
const paces = new Map();
|
|
10
|
+
for (const { streams, interval, timeout } of signals)
|
|
11
|
+
for (const { name } of streams) {
|
|
12
|
+
if (!names.has(name))
|
|
13
|
+
return `A change signal names an unselected stream: ${name}`;
|
|
14
|
+
const pace = `${interval}/${timeout}`;
|
|
15
|
+
const known = paces.get(name);
|
|
16
|
+
if (known !== undefined && known !== pace)
|
|
17
|
+
return `The change signals of stream ${name} have two intervals`;
|
|
18
|
+
paces.set(name, pace);
|
|
19
|
+
}
|
|
20
|
+
const missing = selected.find(({ name }) => !paces.has(name));
|
|
21
|
+
return missing === undefined
|
|
22
|
+
? undefined
|
|
23
|
+
: `No change signal tells about stream ${missing.name}`;
|
|
24
|
+
}
|
|
25
|
+
// At each check of one interval, the outcome of each stream that the check
|
|
26
|
+
// covered, by stream name: changed when one of its gauges changed, else
|
|
27
|
+
// failed when one of them failed, else unchanged. Each interval and timeout
|
|
28
|
+
// has its own change watcher, so a slow read is not made at each check of a
|
|
29
|
+
// fast one (ADR 0020).
|
|
30
|
+
export function watchSignals(signals, options) {
|
|
31
|
+
const paces = new Map();
|
|
32
|
+
for (const { gauge, streams, interval, timeout } of signals) {
|
|
33
|
+
const key = `${interval}/${timeout}`;
|
|
34
|
+
const pace = paces.get(key) ?? { interval, timeout, gauges: new Map() };
|
|
35
|
+
paces.set(key, pace);
|
|
36
|
+
const read = pace.gauges.get(gauge) ?? new SignalGauge(gauge, options.failureType);
|
|
37
|
+
pace.gauges.set(gauge, read);
|
|
38
|
+
read.streams.push(...streams.map(({ name }) => name));
|
|
39
|
+
}
|
|
40
|
+
return interleave([...paces.values()].map((pace) => watchPace(pace, options)), Number.POSITIVE_INFINITY);
|
|
41
|
+
}
|
|
42
|
+
async function* watchPace({ interval, timeout, gauges }, { signal, clock, }) {
|
|
43
|
+
const watcher = new ChangeWatcher(gauges.values(), {
|
|
44
|
+
interval,
|
|
45
|
+
timeout,
|
|
46
|
+
clock,
|
|
47
|
+
});
|
|
48
|
+
for await (const outcomes of watcher.watch(signal)) {
|
|
49
|
+
const ofStream = Map.groupBy([...outcomes].flatMap(([gauge, outcome]) => gauge.streams.map((name) => ({ name, outcome }))), ({ name }) => name);
|
|
50
|
+
yield new Map([...ofStream].map(([name, told]) => [
|
|
51
|
+
name,
|
|
52
|
+
combined(told.map(({ outcome }) => outcome)),
|
|
53
|
+
]));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function combined(outcomes) {
|
|
57
|
+
return (outcomes.find(({ state }) => state === 'changed') ??
|
|
58
|
+
outcomes.find(({ state }) => state === 'failed') ?? { state: 'unchanged' });
|
|
59
|
+
}
|
|
60
|
+
// A gauge of a source as the pipeline reads it: the streams that it tells
|
|
61
|
+
// about, in all the change signals of one interval, and no read after a
|
|
62
|
+
// failure that only the user can fix, as such a failure stopped the whole
|
|
63
|
+
// connection before: a refused login is not tried again at each check. A new
|
|
64
|
+
// watch reads it again (ADR 0020).
|
|
65
|
+
class SignalGauge extends Gauge {
|
|
66
|
+
streams = [];
|
|
67
|
+
#state;
|
|
68
|
+
constructor(gauge, failureType) {
|
|
69
|
+
super();
|
|
70
|
+
this.#state = new Reading(gauge, failureType);
|
|
71
|
+
}
|
|
72
|
+
async read(check) {
|
|
73
|
+
const { result, next } = await this.#state.read(check);
|
|
74
|
+
this.#state = next;
|
|
75
|
+
if (result.status === 'rejected')
|
|
76
|
+
throw result.reason;
|
|
77
|
+
return result.value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Reads the gauge, and halts after a failure of the config type.
|
|
81
|
+
class Reading {
|
|
82
|
+
#gauge;
|
|
83
|
+
#failureType;
|
|
84
|
+
constructor(gauge, failureType) {
|
|
85
|
+
this.#gauge = gauge;
|
|
86
|
+
this.#failureType = failureType;
|
|
87
|
+
}
|
|
88
|
+
async read(check) {
|
|
89
|
+
try {
|
|
90
|
+
const value = await this.#gauge.value(check);
|
|
91
|
+
return { result: { status: 'fulfilled', value }, next: this };
|
|
92
|
+
}
|
|
93
|
+
catch (reason) {
|
|
94
|
+
return {
|
|
95
|
+
result: { status: 'rejected', reason },
|
|
96
|
+
next: this.#failureType(reason) === 'config' ? new Halted(reason) : this,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Gives the failure that halted it, and reads nothing.
|
|
102
|
+
class Halted {
|
|
103
|
+
#reason;
|
|
104
|
+
constructor(reason) {
|
|
105
|
+
this.#reason = reason;
|
|
106
|
+
}
|
|
107
|
+
async read() {
|
|
108
|
+
return { result: { status: 'rejected', reason: this.#reason }, next: this };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DeletionPolicy, type KeptGroup, type SnapshotGroup } from '@zukhruf/snapshot-differential';
|
|
2
|
+
import type { CopyConfiguration } from './copy-configuration.ts';
|
|
3
|
+
import type { DeleteMessage, StateMessage } from './source.ts';
|
|
4
|
+
export type { KeptGroup };
|
|
5
|
+
export type SnapshotDiffOptions = {
|
|
6
|
+
readonly policy?: DeletionPolicy;
|
|
7
|
+
readonly continues?: boolean;
|
|
8
|
+
readonly signal: AbortSignal | undefined;
|
|
9
|
+
};
|
|
10
|
+
type SnapshotMessage<Data> = {
|
|
11
|
+
readonly stream: string;
|
|
12
|
+
readonly data: Data;
|
|
13
|
+
} | DeleteMessage | StateMessage;
|
|
14
|
+
export declare function diffSnapshot<Data extends Record<string, unknown>>({ stream, syncMode }: CopyConfiguration, records: AsyncIterable<Data> | Iterable<Data>, state: unknown, options: SnapshotDiffOptions & {
|
|
15
|
+
readonly rereads?: (record: Data) => boolean;
|
|
16
|
+
}): AsyncGenerator<SnapshotMessage<Data>>;
|
|
17
|
+
export declare function diffGroupedSnapshot<Data extends Record<string, unknown>>({ stream, syncMode }: CopyConfiguration, groups: AsyncIterable<SnapshotGroup<Data> | KeptGroup> | Iterable<SnapshotGroup<Data> | KeptGroup>, state: unknown, options: SnapshotDiffOptions): AsyncGenerator<SnapshotMessage<Data>>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { DeletionPolicy, SnapshotDifferential, } from '@zukhruf/snapshot-differential';
|
|
2
|
+
import { Deduplication } from './deduplication.js';
|
|
3
|
+
// Reads for a source without a change feed. An incremental copy compares one
|
|
4
|
+
// complete scan with the previous snapshot, emits new or changed records, a
|
|
5
|
+
// DELETE for every vanished key the policy judges deleted, then the new
|
|
6
|
+
// snapshot as the only STATE. A full refresh replaces its target, so it emits
|
|
7
|
+
// every record of the scan, one at a time as the scan yields it, and saves
|
|
8
|
+
// nothing. rereads names the lookback window of a scan the next one repeats
|
|
9
|
+
// only in part; continues makes the call one part of a scan.
|
|
10
|
+
export async function* diffSnapshot({ stream, syncMode }, records, state, options) {
|
|
11
|
+
const deduplication = deduplicationOf(stream);
|
|
12
|
+
const differential = differentialOf(deduplication, options);
|
|
13
|
+
if (syncMode !== 'incremental') {
|
|
14
|
+
for await (const data of records)
|
|
15
|
+
yield { stream: stream.name, data };
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
for await (const change of differential.diff(untilStopped(records, options.signal), state, options))
|
|
19
|
+
yield messageOf(deduplication, change);
|
|
20
|
+
}
|
|
21
|
+
// diffSnapshot for a scan whose records come from inputs the source can
|
|
22
|
+
// fingerprint cheaply: in an incremental copy, a group whose fingerprint
|
|
23
|
+
// matches the last committed scan, and a kept group, which the source knows
|
|
24
|
+
// did not change, keep their records without reading them.
|
|
25
|
+
export async function* diffGroupedSnapshot({ stream, syncMode }, groups, state, options) {
|
|
26
|
+
const deduplication = deduplicationOf(stream);
|
|
27
|
+
const differential = differentialOf(deduplication, options);
|
|
28
|
+
if (syncMode !== 'incremental') {
|
|
29
|
+
for await (const group of groups) {
|
|
30
|
+
// A full refresh replaces its target, so it must read every group.
|
|
31
|
+
if ('kept' in group)
|
|
32
|
+
throw new TypeError(`Stream ${stream.name} kept group ${group.key} in a full refresh, which reads every group`);
|
|
33
|
+
for await (const data of group.records())
|
|
34
|
+
yield { stream: stream.name, data };
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for await (const change of differential.diffGroups(untilStopped(groups, options.signal), state, options))
|
|
39
|
+
yield messageOf(deduplication, change);
|
|
40
|
+
}
|
|
41
|
+
// Each item of a scan until signal aborts. A scan that finds nothing changed
|
|
42
|
+
// may never wait on I/O, and then no other task runs until it ends: not the
|
|
43
|
+
// host's stop, its timers nor the requests it serves. So the scan gives the
|
|
44
|
+
// event loop a turn every turnItems items, and once the run is cancelled it
|
|
45
|
+
// ends with the signal's reason, never as a complete scan the differential
|
|
46
|
+
// could delete the unread keys of.
|
|
47
|
+
async function* untilStopped(items, signal) {
|
|
48
|
+
let read = 0;
|
|
49
|
+
for await (const item of items) {
|
|
50
|
+
if (++read % turnItems === 0)
|
|
51
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
yield item;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// The most items a scan reads without letting another task run. A count, not
|
|
57
|
+
// a time, so the turns do not depend on a clock: an item diffs in
|
|
58
|
+
// microseconds, so a turn comes within a few milliseconds.
|
|
59
|
+
const turnItems = 1000;
|
|
60
|
+
// A stream that declares no emitsDeletes has an upstream that forgets
|
|
61
|
+
// records rather than deleting them: every vanished key leaves the snapshot
|
|
62
|
+
// without a DELETE, and its row stays loaded.
|
|
63
|
+
class Forgetting extends DeletionPolicy {
|
|
64
|
+
vanished() {
|
|
65
|
+
return 'expired';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function deduplicationOf(stream) {
|
|
69
|
+
if (!stream.sourceDefinedCursor)
|
|
70
|
+
throw new TypeError(`Stream ${stream.name} must declare sourceDefinedCursor to diff snapshots`);
|
|
71
|
+
return new Deduplication(stream, stream.primaryKey);
|
|
72
|
+
}
|
|
73
|
+
function differentialOf(deduplication, { policy }) {
|
|
74
|
+
const { stream } = deduplication;
|
|
75
|
+
return new SnapshotDifferential({
|
|
76
|
+
name: stream.name,
|
|
77
|
+
primaryKey: stream.primaryKey,
|
|
78
|
+
key: (record) => deduplication.primaryKey.map((field) => deduplication.value(record, field)),
|
|
79
|
+
expiresBy: stream.expiresBy,
|
|
80
|
+
policy: policyOf(stream, policy),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function policyOf(stream, policy) {
|
|
84
|
+
if (!stream.emitsDeletes) {
|
|
85
|
+
if (policy !== undefined)
|
|
86
|
+
throw new TypeError(`Stream ${stream.name} emits no deletions, so its snapshot diff takes no policy`);
|
|
87
|
+
return new Forgetting();
|
|
88
|
+
}
|
|
89
|
+
if (policy === undefined)
|
|
90
|
+
throw new TypeError(stream.expiresBy === undefined
|
|
91
|
+
? `Stream ${stream.name} emits deletions, so its snapshot diff needs a policy, such as ClosedWorld`
|
|
92
|
+
: `Stream ${stream.name} expires by ${stream.expiresBy}, so its snapshot diff needs a policy, such as Retention(horizon)`);
|
|
93
|
+
return policy;
|
|
94
|
+
}
|
|
95
|
+
function messageOf(deduplication, change) {
|
|
96
|
+
const { stream } = deduplication;
|
|
97
|
+
if (change.type === 'insert' || change.type === 'update')
|
|
98
|
+
return { stream: stream.name, data: change.record };
|
|
99
|
+
if (change.type === 'delete')
|
|
100
|
+
return {
|
|
101
|
+
type: 'DELETE',
|
|
102
|
+
stream: stream.name,
|
|
103
|
+
// The differential can keep bytes in a key; a stream's key fields are
|
|
104
|
+
// scalars, so the deleted key passes the same check its record did.
|
|
105
|
+
key: Object.fromEntries(deduplication.primaryKey.map((field) => [
|
|
106
|
+
field,
|
|
107
|
+
deduplication.value(change.key, field),
|
|
108
|
+
])),
|
|
109
|
+
};
|
|
110
|
+
return { type: 'STATE', stream: stream.name, state: change.state };
|
|
111
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Catalog } from './catalog.ts';
|
|
2
|
+
import type { ChangeSignal } from './change-signal.ts';
|
|
3
|
+
import type { PipelineClock } from './clock.ts';
|
|
4
|
+
import type { CopyConfiguration } from './copy-configuration.ts';
|
|
5
|
+
import { type Partition } from './partition.ts';
|
|
6
|
+
import type { Stream } from './stream.ts';
|
|
7
|
+
import type { Upkeep } from './upkeep.ts';
|
|
8
|
+
export type SourceReadOptions = {
|
|
9
|
+
readonly signal?: AbortSignal | undefined;
|
|
10
|
+
readonly clock: PipelineClock;
|
|
11
|
+
};
|
|
12
|
+
export type RecordMessage = {
|
|
13
|
+
readonly stream: string;
|
|
14
|
+
readonly data: unknown;
|
|
15
|
+
readonly file?: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type StateMessage = {
|
|
18
|
+
readonly type: 'STATE';
|
|
19
|
+
readonly stream: string;
|
|
20
|
+
readonly state: unknown;
|
|
21
|
+
};
|
|
22
|
+
export type KeyValue = string | number | boolean;
|
|
23
|
+
export type ExtractionCoverage = {
|
|
24
|
+
readonly description: string;
|
|
25
|
+
readonly selection: {
|
|
26
|
+
readonly [field: string]: JsonValue;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export type FailureType = 'config' | 'system';
|
|
30
|
+
export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
|
|
31
|
+
readonly [field: string]: JsonValue;
|
|
32
|
+
};
|
|
33
|
+
export type DeleteMessage = {
|
|
34
|
+
readonly type: 'DELETE';
|
|
35
|
+
readonly stream: string;
|
|
36
|
+
readonly key: Readonly<Record<string, KeyValue>>;
|
|
37
|
+
};
|
|
38
|
+
export type ResetMessage = {
|
|
39
|
+
readonly type: 'RESET';
|
|
40
|
+
readonly stream: string;
|
|
41
|
+
};
|
|
42
|
+
export type ScopedReset = ResetMessage & {
|
|
43
|
+
readonly partition: Partition | null;
|
|
44
|
+
};
|
|
45
|
+
export type SourceMessage = RecordMessage | StateMessage | DeleteMessage | ResetMessage;
|
|
46
|
+
export declare class StreamStatus {
|
|
47
|
+
readonly stream: string;
|
|
48
|
+
readonly status: 'STARTED' | 'FAILED' | 'ENDED';
|
|
49
|
+
readonly partition: Partition | null;
|
|
50
|
+
readonly error: unknown;
|
|
51
|
+
constructor(stream: string, status: 'STARTED' | 'FAILED' | 'ENDED', partition?: Partition | null, error?: unknown);
|
|
52
|
+
}
|
|
53
|
+
export type ReadMessage = RecordMessage | StateMessage | DeleteMessage | ScopedReset | StreamStatus;
|
|
54
|
+
export declare abstract class Source<Context extends AsyncDisposable = AsyncDisposable> {
|
|
55
|
+
#private;
|
|
56
|
+
abstract readonly identity: string;
|
|
57
|
+
protected abstract readonly catalog: Catalog;
|
|
58
|
+
protected readonly concurrency: number;
|
|
59
|
+
discover(): Promise<Catalog>;
|
|
60
|
+
validate(configuration: CopyConfiguration): void;
|
|
61
|
+
protected abstract open(streams: readonly Stream[], signal: AbortSignal | undefined, states: {
|
|
62
|
+
get(stream: string): unknown;
|
|
63
|
+
}, clock: PipelineClock): Promise<Context>;
|
|
64
|
+
protected validateExtraction(_configuration: CopyConfiguration): void;
|
|
65
|
+
protected partitions(stream: Stream): readonly Partition[];
|
|
66
|
+
abstract coverage(stream: Stream): ExtractionCoverage;
|
|
67
|
+
abstract currentAsOf(stream: Stream): Promise<Date | null>;
|
|
68
|
+
protected readonly configErrors: readonly (abstract new (...args: never[]) => Error)[];
|
|
69
|
+
failureType(error: unknown): FailureType;
|
|
70
|
+
abstract changeSignals(streams: readonly Stream[], clock: PipelineClock): Iterable<ChangeSignal>;
|
|
71
|
+
upkeep(_clock: PipelineClock): Iterable<Upkeep>;
|
|
72
|
+
private member;
|
|
73
|
+
read(catalog: readonly CopyConfiguration[], states: {
|
|
74
|
+
get(stream: string): unknown;
|
|
75
|
+
}, { signal, clock }: SourceReadOptions): AsyncGenerator<ReadMessage>;
|
|
76
|
+
private resolved;
|
|
77
|
+
private partitioned;
|
|
78
|
+
protected abstract extract(configuration: CopyConfiguration, state: unknown, partition: Partition | null, context: Context, signal: AbortSignal | undefined): AsyncIterable<SourceMessage>;
|
|
79
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { FileContent } from './file-content.js';
|
|
2
|
+
import { interleave } from './interleave.js';
|
|
3
|
+
import { assertInPartition, assertPartitions, partitionIdentity, readPartition, readPartitionStates, } from './partition.js';
|
|
4
|
+
// Where a stream's read stands, as Airbyte's stream status: STARTED before its
|
|
5
|
+
// first message; FAILED when one partition, or the whole stream when
|
|
6
|
+
// partition is null, did not read; ENDED after its last message. Only
|
|
7
|
+
// Source.read creates it, from what extract threw; a connector signals
|
|
8
|
+
// failure by throwing.
|
|
9
|
+
export class StreamStatus {
|
|
10
|
+
stream;
|
|
11
|
+
status;
|
|
12
|
+
partition;
|
|
13
|
+
error;
|
|
14
|
+
constructor(stream, status, partition = null, error = undefined) {
|
|
15
|
+
this.stream = stream;
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.partition = partition;
|
|
18
|
+
this.error = error;
|
|
19
|
+
Object.freeze(this);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Airbyte's read(config, catalog, state): one read covers every selected
|
|
23
|
+
// stream, inside one context the source opens for it, so related streams see
|
|
24
|
+
// the same moment of the upstream.
|
|
25
|
+
export class Source {
|
|
26
|
+
// How many streams one read reads at once; their messages interleave. A
|
|
27
|
+
// source raising it reads its context from several extracts together.
|
|
28
|
+
concurrency = 1;
|
|
29
|
+
async discover() {
|
|
30
|
+
return this.catalog;
|
|
31
|
+
}
|
|
32
|
+
// Check source-owned metadata without extraction or rediscovery.
|
|
33
|
+
validate(configuration) {
|
|
34
|
+
const stream = this.member(configuration.stream);
|
|
35
|
+
configuration.validate(stream);
|
|
36
|
+
this.validateExtraction(configuration);
|
|
37
|
+
if (stream.partitionKey !== undefined)
|
|
38
|
+
assertPartitions(stream, this.partitions(stream));
|
|
39
|
+
}
|
|
40
|
+
// Source-specific selection rules, checked without I/O.
|
|
41
|
+
validateExtraction(_configuration) { }
|
|
42
|
+
// The partitions a partitioned stream is read as, asked at the start of
|
|
43
|
+
// each read and at the start of a watch, so a source whose host changes
|
|
44
|
+
// them, such as a web stream's inputs, gives the list as it is then; none
|
|
45
|
+
// is a stream that reads nothing. The list is not part of the identity:
|
|
46
|
+
// adding or removing a partition keeps every other partition's checkpoint.
|
|
47
|
+
partitions(stream) {
|
|
48
|
+
throw new TypeError(`Source must declare partitions for stream ${stream.name}`);
|
|
49
|
+
}
|
|
50
|
+
// The error classes that mean the user must act, such as an SDK's access
|
|
51
|
+
// error. Only the source knows which of its upstream's errors those are.
|
|
52
|
+
configErrors = [];
|
|
53
|
+
// What kind of failure an error from this source is: config for an instance
|
|
54
|
+
// of a class in configErrors, and system for any other error, a
|
|
55
|
+
// destination's included. A source whose decision needs more than the
|
|
56
|
+
// class, such as an HTTP status, overrides it.
|
|
57
|
+
failureType(error) {
|
|
58
|
+
return this.configErrors.some((type) => error instanceof type)
|
|
59
|
+
? 'config'
|
|
60
|
+
: 'system';
|
|
61
|
+
}
|
|
62
|
+
// What the source keeps doing while a watch runs, such as keeping an app
|
|
63
|
+
// running when only that app syncs its store (ADR 0021). Most sources keep
|
|
64
|
+
// nothing.
|
|
65
|
+
upkeep(_clock) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
member(stream) {
|
|
69
|
+
if (this.catalog.get(stream.name) !== stream)
|
|
70
|
+
throw new TypeError(`Stream ${stream.name} is not from this source's discovered catalog`);
|
|
71
|
+
return stream;
|
|
72
|
+
}
|
|
73
|
+
// Must be lazy: the destination prepares its targets before pulling records.
|
|
74
|
+
// Each checkpoint is a commit point. A full refresh carries none, so it
|
|
75
|
+
// loads all or nothing; an incremental read that fails keeps what earlier
|
|
76
|
+
// checkpoints committed. states holds each incremental stream's saved state,
|
|
77
|
+
// or a promise of it, by stream name; a read asks for a stream's state when
|
|
78
|
+
// it starts that stream, so a state that grows with its upstream, such as a
|
|
79
|
+
// snapshot, is held only while its own stream reads. signal is the run's:
|
|
80
|
+
// each extract receives it, so one waiting on its upstream can stop once
|
|
81
|
+
// the run is cancelled. Streams that read together are one task group, as
|
|
82
|
+
// Python's asyncio.TaskGroup: the first one that throws aborts the signal
|
|
83
|
+
// the others read with, so a stream that waits on another, such as a
|
|
84
|
+
// consumer of a sibling's outbox, ends with the read.
|
|
85
|
+
async *read(catalog, states, { signal, clock }) {
|
|
86
|
+
for (const configuration of catalog)
|
|
87
|
+
this.validate(configuration);
|
|
88
|
+
if (new Set(catalog.map(({ stream }) => stream.name)).size !== catalog.length)
|
|
89
|
+
throw new TypeError('A read selects each stream once');
|
|
90
|
+
const group = new AbortController();
|
|
91
|
+
const reading = signal === undefined
|
|
92
|
+
? group.signal
|
|
93
|
+
: AbortSignal.any([signal, group.signal]);
|
|
94
|
+
await using context = await this.open(catalog.map(({ stream }) => stream), reading, states, clock);
|
|
95
|
+
if (!Number.isSafeInteger(this.concurrency) || this.concurrency < 1)
|
|
96
|
+
throw new TypeError('Source concurrency must be a positive integer');
|
|
97
|
+
yield* interleave(catalog.map((configuration) => grouped(this.#stream(configuration, states, context, reading), group)), this.concurrency);
|
|
98
|
+
}
|
|
99
|
+
async *#stream(configuration, states, context, signal) {
|
|
100
|
+
const { name } = configuration.stream;
|
|
101
|
+
yield new StreamStatus(name, 'STARTED');
|
|
102
|
+
const state = (await states.get(name)) ?? null;
|
|
103
|
+
if (configuration.stream.partitionKey !== undefined)
|
|
104
|
+
yield* this.partitioned(configuration, state, context, signal);
|
|
105
|
+
else {
|
|
106
|
+
const incremental = configuration.syncMode === 'incremental';
|
|
107
|
+
try {
|
|
108
|
+
for await (const message of this.resolved(configuration, this.extract(configuration, state, null, context, signal), signal))
|
|
109
|
+
if (!('type' in message) || message.type === 'DELETE')
|
|
110
|
+
yield message;
|
|
111
|
+
else if (incremental)
|
|
112
|
+
yield message.type === 'RESET'
|
|
113
|
+
? { ...message, partition: null }
|
|
114
|
+
: message;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
yield new StreamStatus(name, 'FAILED', null, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
yield new StreamStatus(name, 'ENDED');
|
|
121
|
+
}
|
|
122
|
+
// Keeps each message to the stream whose extract emitted it, and replaces
|
|
123
|
+
// each record's file path with the file reads it asked for.
|
|
124
|
+
async *resolved(configuration, messages, signal) {
|
|
125
|
+
for await (const message of messages) {
|
|
126
|
+
if (message instanceof StreamStatus)
|
|
127
|
+
throw new TypeError('Only Source.read reports stream status; extract signals failure by throwing');
|
|
128
|
+
if (message.stream !== configuration.stream.name)
|
|
129
|
+
throw new TypeError(`Extract for ${configuration.stream.name} emitted ${message.stream}`);
|
|
130
|
+
if ('type' in message &&
|
|
131
|
+
message.type === 'RESET' &&
|
|
132
|
+
Object.hasOwn(message, 'partition'))
|
|
133
|
+
throw new TypeError('Only Source.read scopes a reset to its partition');
|
|
134
|
+
if ('type' in message || configuration.fileReads.length === 0) {
|
|
135
|
+
yield message;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (message.file !== null && typeof message.file !== 'string')
|
|
139
|
+
throw new TypeError('File extraction must supply a file path or explicit null');
|
|
140
|
+
const data = message.data;
|
|
141
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data))
|
|
142
|
+
throw new TypeError('File metadata must be an object');
|
|
143
|
+
const output = { ...data };
|
|
144
|
+
const values = new Map();
|
|
145
|
+
for (const read of configuration.fileReads) {
|
|
146
|
+
if (Object.keys(data).some((name) => name.toLowerCase() === read.name.toLowerCase()))
|
|
147
|
+
throw new TypeError('File field collides with source metadata');
|
|
148
|
+
let value = null;
|
|
149
|
+
if (message.file !== null) {
|
|
150
|
+
let pending = values.get(read.parser);
|
|
151
|
+
if (pending === undefined) {
|
|
152
|
+
pending =
|
|
153
|
+
read.parser === undefined
|
|
154
|
+
? new FileContent(message.file)
|
|
155
|
+
: read.parser.parse(message.file, signal);
|
|
156
|
+
values.set(read.parser, pending);
|
|
157
|
+
}
|
|
158
|
+
value = await pending;
|
|
159
|
+
if (read.parser !== undefined &&
|
|
160
|
+
value !== null &&
|
|
161
|
+
typeof value !== 'string')
|
|
162
|
+
throw new TypeError('Document parser must return text or null');
|
|
163
|
+
}
|
|
164
|
+
Object.defineProperty(output, read.name, { value, enumerable: true });
|
|
165
|
+
}
|
|
166
|
+
yield { stream: message.stream, data: output };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Reads each partition with its own saved state: null for a partition not
|
|
170
|
+
// seen before, so it starts from the source's normal beginning. Every
|
|
171
|
+
// checkpoint carries all listed partitions' latest states, so a partition
|
|
172
|
+
// not yet read, or one that failed, keeps what it had. Partitions no longer
|
|
173
|
+
// listed drop out of the next checkpoint; their rows stay loaded.
|
|
174
|
+
async *partitioned(configuration, state, context, signal) {
|
|
175
|
+
const { stream } = configuration;
|
|
176
|
+
const incremental = configuration.syncMode === 'incremental';
|
|
177
|
+
const saved = incremental
|
|
178
|
+
? readPartitionStates(stream, state)
|
|
179
|
+
: new Map();
|
|
180
|
+
const identity = partitionIdentity(stream);
|
|
181
|
+
const listed = this.partitions(stream).map((entry) => readPartition(identity, entry));
|
|
182
|
+
const latest = new Map(saved);
|
|
183
|
+
for (const { key, partition } of listed) {
|
|
184
|
+
try {
|
|
185
|
+
for await (const message of this.resolved(configuration, this.extract(configuration, saved.get(key)?.state ?? null, partition, context, signal), signal)) {
|
|
186
|
+
if ('type' in message && message.type === 'STATE') {
|
|
187
|
+
if (!incremental)
|
|
188
|
+
continue;
|
|
189
|
+
latest.set(key, { partition, state: message.state });
|
|
190
|
+
yield {
|
|
191
|
+
type: 'STATE',
|
|
192
|
+
stream: stream.name,
|
|
193
|
+
state: {
|
|
194
|
+
partitions: listed.flatMap((entry) => latest.get(entry.key) ?? []),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if ('type' in message && message.type === 'RESET') {
|
|
200
|
+
if (incremental)
|
|
201
|
+
yield { ...message, partition };
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
assertInPartition(stream, partition, 'type' in message ? message.key : message.data);
|
|
205
|
+
yield message;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
yield new StreamStatus(stream.name, 'FAILED', partition, error);
|
|
210
|
+
// A full refresh commits nothing after a failure; stop reading.
|
|
211
|
+
if (!incremental)
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// A stream that throws aborts its group before the throw reaches interleave,
|
|
218
|
+
// which waits for every other stream to settle.
|
|
219
|
+
async function* grouped(messages, group) {
|
|
220
|
+
try {
|
|
221
|
+
yield* messages;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
group.abort(error);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
// The declarations that say how a stream's records are identified, read and
|
|
3
|
+
// deleted. Changing one changes what the stored rows mean, so their copy
|
|
4
|
+
// cannot carry on from its checkpoint. Airbyte pauses a connection on the
|
|
5
|
+
// same kind of change, a removed primary key or cursor, and asks the user to
|
|
6
|
+
// refresh or clear.
|
|
7
|
+
const declarations = [
|
|
8
|
+
'name',
|
|
9
|
+
'primaryKey',
|
|
10
|
+
'partitionKey',
|
|
11
|
+
'supportedSyncModes',
|
|
12
|
+
'supportsFileTransfer',
|
|
13
|
+
'sourceDefinedCursor',
|
|
14
|
+
'emitsDeletes',
|
|
15
|
+
'expiresBy',
|
|
16
|
+
];
|
|
17
|
+
// A copy's stream changed one of its declarations since its checkpoint was
|
|
18
|
+
// saved. The host decides what happens to the stored rows: a reset keeps them
|
|
19
|
+
// and loads on from no checkpoint, a clear drops them.
|
|
20
|
+
export class StreamChangeError extends TypeError {
|
|
21
|
+
name = 'StreamChangeError';
|
|
22
|
+
constructor(id, changed) {
|
|
23
|
+
super(`Stream of copy ${id} changed its ${changed.join(', ')}; reset the copy to keep its rows, or clear it to drop them`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// The declarations a stream changed between two of its shapes, as a
|
|
27
|
+
// checkpoint binding keeps them. Any other change, to its fields and their
|
|
28
|
+
// types, is one the destination evolves its target to.
|
|
29
|
+
export function changedDeclarations(saved, current) {
|
|
30
|
+
return declarations.filter((declaration) => !isDeepStrictEqual(Reflect.get(Object(saved), declaration), Reflect.get(Object(current), declaration)));
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FileReference } from './file-read.ts';
|
|
2
|
+
import type { StreamSchema } from './record-validation.ts';
|
|
3
|
+
export type SyncMode = 'full_refresh' | 'incremental';
|
|
4
|
+
export declare class Stream {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly jsonSchema: StreamSchema;
|
|
7
|
+
readonly primaryKey: readonly string[];
|
|
8
|
+
readonly supportedSyncModes: readonly SyncMode[];
|
|
9
|
+
readonly supportsFileTransfer?: true;
|
|
10
|
+
readonly sourceDefinedCursor?: true;
|
|
11
|
+
readonly emitsDeletes?: true;
|
|
12
|
+
readonly partitionKey?: readonly string[];
|
|
13
|
+
readonly expiresBy?: string;
|
|
14
|
+
constructor({ name, jsonSchema, primaryKey, supportedSyncModes, supportsFileTransfer, sourceDefinedCursor, emitsDeletes, partitionKey, expiresBy, }: {
|
|
15
|
+
name: string;
|
|
16
|
+
jsonSchema: StreamSchema;
|
|
17
|
+
primaryKey?: readonly string[];
|
|
18
|
+
supportedSyncModes: readonly SyncMode[];
|
|
19
|
+
supportsFileTransfer?: true;
|
|
20
|
+
sourceDefinedCursor?: true;
|
|
21
|
+
emitsDeletes?: true;
|
|
22
|
+
partitionKey?: readonly string[];
|
|
23
|
+
expiresBy?: string;
|
|
24
|
+
});
|
|
25
|
+
get file(): FileReference;
|
|
26
|
+
}
|