@eltjs/elt 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 +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,23 @@
|
|
|
1
|
+
import { Stream } from './stream.js';
|
|
2
|
+
// Available streams and their logical schemas. A selection is another catalog.
|
|
3
|
+
export class Catalog {
|
|
4
|
+
streams;
|
|
5
|
+
constructor(streams) {
|
|
6
|
+
this.streams = Object.freeze(Array.from(streams));
|
|
7
|
+
if (!this.streams.every((stream) => stream instanceof Stream))
|
|
8
|
+
throw new TypeError('A catalog requires stream descriptions');
|
|
9
|
+
if (new Set(this.streams.map((stream) => stream.name)).size !==
|
|
10
|
+
this.streams.length)
|
|
11
|
+
throw new TypeError('A catalog cannot contain duplicate stream names');
|
|
12
|
+
Object.freeze(this);
|
|
13
|
+
}
|
|
14
|
+
get(name) {
|
|
15
|
+
const stream = this.streams.find((stream) => stream.name === name);
|
|
16
|
+
if (!stream)
|
|
17
|
+
throw new TypeError(`Unknown stream: ${name}`);
|
|
18
|
+
return stream;
|
|
19
|
+
}
|
|
20
|
+
select(streams) {
|
|
21
|
+
return new Catalog(Array.from(streams, (stream) => this.get(stream.name)));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Gauge } from '@zukhruf/async';
|
|
2
|
+
import type { Stream } from './stream.ts';
|
|
3
|
+
export { type Check, Gauge } from '@zukhruf/async';
|
|
4
|
+
export type ChangeSignal = {
|
|
5
|
+
readonly gauge: Gauge;
|
|
6
|
+
readonly streams: readonly Stream[];
|
|
7
|
+
readonly interval: number;
|
|
8
|
+
readonly timeout: number;
|
|
9
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CheckpointStore } from '../state/checkpoint-store.ts';
|
|
2
|
+
import { Copy } from './copy.ts';
|
|
3
|
+
import type { Destination } from './destination.ts';
|
|
4
|
+
import type { Source } from './source.ts';
|
|
5
|
+
import type { Target as DestinationTarget } from './target.ts';
|
|
6
|
+
export declare class Connection<Target extends DestinationTarget> {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly source: Source;
|
|
9
|
+
readonly destination: Destination<Target>;
|
|
10
|
+
readonly checkpoints?: CheckpointStore;
|
|
11
|
+
readonly steps: readonly Copy<Target>[];
|
|
12
|
+
constructor({ name, source, destination, steps, checkpoints, }: {
|
|
13
|
+
name: string;
|
|
14
|
+
source: Source;
|
|
15
|
+
destination: Destination<Target>;
|
|
16
|
+
steps: readonly Copy<NoInfer<Target>>[];
|
|
17
|
+
checkpoints?: CheckpointStore;
|
|
18
|
+
});
|
|
19
|
+
validate(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Copy } from './copy.js';
|
|
2
|
+
// Airbyte's connection: one source's copies into one destination, with the
|
|
3
|
+
// checkpoints that resume them. Its name is what readers of sync history see.
|
|
4
|
+
export class Connection {
|
|
5
|
+
name;
|
|
6
|
+
source;
|
|
7
|
+
destination;
|
|
8
|
+
checkpoints;
|
|
9
|
+
steps;
|
|
10
|
+
constructor({ name, source, destination, steps, checkpoints, }) {
|
|
11
|
+
if (!name.trim() || name.includes('\0'))
|
|
12
|
+
throw new TypeError('A connection requires a name');
|
|
13
|
+
if (!steps.every((step) => step instanceof Copy))
|
|
14
|
+
throw new TypeError('Connection steps must be Copy declarations');
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.source = source;
|
|
17
|
+
this.destination = destination;
|
|
18
|
+
this.checkpoints = checkpoints;
|
|
19
|
+
this.steps = Object.freeze([...steps]);
|
|
20
|
+
Object.freeze(this);
|
|
21
|
+
}
|
|
22
|
+
validate() {
|
|
23
|
+
// One read routes messages and states by stream, as Airbyte's configured
|
|
24
|
+
// catalog lists each stream once.
|
|
25
|
+
const streams = this.steps.map((copy) => copy.from.name);
|
|
26
|
+
if (new Set(streams).size !== streams.length)
|
|
27
|
+
throw new TypeError(`Connection ${this.name} copies each stream once`);
|
|
28
|
+
for (const copy of this.steps)
|
|
29
|
+
copy.validate(this.source, this.destination, this.checkpoints);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Deduplication } from './deduplication.ts';
|
|
2
|
+
import type { DestinationSyncMode } from './destination.ts';
|
|
3
|
+
import { FileRead } from './file-read.ts';
|
|
4
|
+
import { Stream, type SyncMode } from './stream.ts';
|
|
5
|
+
export type DedupPolicy = 'cursor_newer' | 'replace';
|
|
6
|
+
export declare class CopyConfiguration {
|
|
7
|
+
readonly stream: Stream;
|
|
8
|
+
readonly fileReads: readonly FileRead[];
|
|
9
|
+
readonly syncMode: SyncMode;
|
|
10
|
+
readonly destinationSyncMode: DestinationSyncMode;
|
|
11
|
+
readonly cursorField?: string;
|
|
12
|
+
readonly primaryKey?: readonly string[];
|
|
13
|
+
readonly dedupPolicy?: DedupPolicy;
|
|
14
|
+
constructor(from: Stream, { syncMode, destinationSyncMode, cursorField, primaryKey, dedupPolicy, }: {
|
|
15
|
+
syncMode: SyncMode;
|
|
16
|
+
destinationSyncMode: DestinationSyncMode;
|
|
17
|
+
cursorField?: string;
|
|
18
|
+
primaryKey?: readonly string[];
|
|
19
|
+
dedupPolicy?: DedupPolicy;
|
|
20
|
+
}, fileReads?: readonly FileRead[]);
|
|
21
|
+
validate(stream: Stream): void;
|
|
22
|
+
validateSelection(): void;
|
|
23
|
+
deduplication(): Deduplication;
|
|
24
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Deduplication } from './deduplication.js';
|
|
2
|
+
import { FileRead } from './file-read.js';
|
|
3
|
+
import { Stream } from './stream.js';
|
|
4
|
+
// A copy's immutable selection, separate from discovery metadata and runtime state.
|
|
5
|
+
export class CopyConfiguration {
|
|
6
|
+
stream;
|
|
7
|
+
fileReads;
|
|
8
|
+
syncMode;
|
|
9
|
+
destinationSyncMode;
|
|
10
|
+
cursorField;
|
|
11
|
+
primaryKey;
|
|
12
|
+
// Set only for deduplicating loads; cursor_newer unless the copy selects replace.
|
|
13
|
+
dedupPolicy;
|
|
14
|
+
constructor(from, { syncMode, destinationSyncMode, cursorField, primaryKey, dedupPolicy, }, fileReads = []) {
|
|
15
|
+
if (!(from instanceof Stream))
|
|
16
|
+
throw new TypeError('Copy requires a stream description');
|
|
17
|
+
if (!Array.isArray(fileReads) ||
|
|
18
|
+
!fileReads.every((read) => read instanceof FileRead))
|
|
19
|
+
throw new TypeError('Copy requires FileRead declarations');
|
|
20
|
+
if (new Set(fileReads.map((read) => read.name.toLowerCase())).size !==
|
|
21
|
+
fileReads.length)
|
|
22
|
+
throw new TypeError('Duplicate file field names');
|
|
23
|
+
if (typeof syncMode !== 'string' || typeof destinationSyncMode !== 'string')
|
|
24
|
+
throw new TypeError('Both syncMode and destinationSyncMode are required');
|
|
25
|
+
if (primaryKey !== undefined && !Array.isArray(primaryKey))
|
|
26
|
+
throw new TypeError('primaryKey must be an array of field names');
|
|
27
|
+
// As Airbyte's source_defined_primary_key, a key the stream declares is
|
|
28
|
+
// the key; a copy selects one only for a stream without.
|
|
29
|
+
if (primaryKey !== undefined && from.primaryKey.length > 0)
|
|
30
|
+
throw new TypeError(`Stream ${from.name} defines its own primary key; omit primaryKey`);
|
|
31
|
+
const deduplicating = destinationSyncMode === 'append_dedup' ||
|
|
32
|
+
destinationSyncMode === 'overwrite_dedup';
|
|
33
|
+
this.stream = from;
|
|
34
|
+
this.fileReads = Object.freeze([...fileReads]);
|
|
35
|
+
this.syncMode = syncMode;
|
|
36
|
+
this.destinationSyncMode = destinationSyncMode;
|
|
37
|
+
this.cursorField = cursorField;
|
|
38
|
+
const key = deduplicating && from.primaryKey.length > 0
|
|
39
|
+
? from.primaryKey
|
|
40
|
+
: primaryKey;
|
|
41
|
+
this.primaryKey = key === undefined ? undefined : Object.freeze([...key]);
|
|
42
|
+
// A source-defined cursor leaves no field to compare, so the newest extraction wins.
|
|
43
|
+
this.dedupPolicy = deduplicating
|
|
44
|
+
? (dedupPolicy ?? (from.sourceDefinedCursor ? 'replace' : 'cursor_newer'))
|
|
45
|
+
: dedupPolicy;
|
|
46
|
+
Object.freeze(this);
|
|
47
|
+
}
|
|
48
|
+
validate(stream) {
|
|
49
|
+
if (!this.stream.supportedSyncModes.includes(this.syncMode) ||
|
|
50
|
+
!stream.supportedSyncModes.includes(this.syncMode))
|
|
51
|
+
throw new TypeError(`Stream ${stream.name} does not support ${this.syncMode}`);
|
|
52
|
+
this.validateSelection();
|
|
53
|
+
for (const read of this.fileReads)
|
|
54
|
+
read.validate(stream);
|
|
55
|
+
}
|
|
56
|
+
validateSelection() {
|
|
57
|
+
for (const read of this.fileReads)
|
|
58
|
+
read.validate(this.stream);
|
|
59
|
+
const { syncMode, destinationSyncMode, cursorField, primaryKey, dedupPolicy, } = this;
|
|
60
|
+
if (syncMode !== 'full_refresh' && syncMode !== 'incremental')
|
|
61
|
+
throw new TypeError(`Unsupported syncMode: ${syncMode}`);
|
|
62
|
+
if (syncMode === 'incremental' &&
|
|
63
|
+
(destinationSyncMode === 'overwrite' ||
|
|
64
|
+
destinationSyncMode === 'overwrite_dedup'))
|
|
65
|
+
throw new TypeError('Incremental extraction cannot use overwrite loading');
|
|
66
|
+
if (syncMode === 'full_refresh' && destinationSyncMode === 'append_dedup')
|
|
67
|
+
throw new TypeError('Full refresh deduplication requires overwrite_dedup');
|
|
68
|
+
const { sourceDefinedCursor, emitsDeletes } = this.stream;
|
|
69
|
+
if (sourceDefinedCursor && cursorField !== undefined)
|
|
70
|
+
throw new TypeError(`Stream ${this.stream.name} defines its own cursor; omit cursorField`);
|
|
71
|
+
// A cursor field the stream dropped is a breaking change, as Airbyte
|
|
72
|
+
// treats a removed cursor; the copy must choose another.
|
|
73
|
+
if (cursorField !== undefined &&
|
|
74
|
+
!Object.hasOwn(this.stream.jsonSchema.properties ?? {}, cursorField))
|
|
75
|
+
throw new TypeError(`Stream ${this.stream.name} does not describe cursor field ${cursorField}`);
|
|
76
|
+
if (syncMode === 'incremental' &&
|
|
77
|
+
!sourceDefinedCursor &&
|
|
78
|
+
(typeof cursorField !== 'string' || !cursorField))
|
|
79
|
+
throw new TypeError('Incremental extraction requires cursorField');
|
|
80
|
+
if (emitsDeletes &&
|
|
81
|
+
syncMode === 'incremental' &&
|
|
82
|
+
destinationSyncMode !== 'append_dedup')
|
|
83
|
+
throw new TypeError(`Stream ${this.stream.name} emits deletions; incremental copies require append_dedup`);
|
|
84
|
+
if (dedupPolicy !== undefined &&
|
|
85
|
+
dedupPolicy !== 'cursor_newer' &&
|
|
86
|
+
dedupPolicy !== 'replace')
|
|
87
|
+
throw new TypeError(`Unsupported dedupPolicy: ${dedupPolicy}`);
|
|
88
|
+
if (destinationSyncMode === 'append_dedup' ||
|
|
89
|
+
destinationSyncMode === 'overwrite_dedup') {
|
|
90
|
+
if (primaryKey === undefined)
|
|
91
|
+
throw new TypeError(`Stream ${this.stream.name} declares no primary key; select primaryKey`);
|
|
92
|
+
if (dedupPolicy === 'cursor_newer' && cursorField === undefined)
|
|
93
|
+
throw new TypeError(sourceDefinedCursor
|
|
94
|
+
? `Stream ${this.stream.name} has no cursor field to compare; deduplicate with dedupPolicy 'replace'`
|
|
95
|
+
: 'cursor_newer deduplication requires cursorField');
|
|
96
|
+
new Deduplication(this.stream, primaryKey, cursorField);
|
|
97
|
+
// A cursor inside the primary key is equal on every conflict, so the
|
|
98
|
+
// cursor_newer guard can never fire and restated facts load as no-ops.
|
|
99
|
+
if (dedupPolicy === 'cursor_newer' &&
|
|
100
|
+
cursorField !== undefined &&
|
|
101
|
+
primaryKey.includes(cursorField))
|
|
102
|
+
throw new TypeError(`Cursor field ${cursorField} is part of the primary key, so cursor_newer can never update a conflicting row; select dedupPolicy 'replace' to let the newest extraction win`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
if (primaryKey !== undefined)
|
|
106
|
+
throw new TypeError('Select primaryKey only for deduplication loading');
|
|
107
|
+
if (dedupPolicy !== undefined)
|
|
108
|
+
throw new TypeError('Select dedupPolicy only for deduplication loading');
|
|
109
|
+
}
|
|
110
|
+
if (syncMode === 'full_refresh' &&
|
|
111
|
+
destinationSyncMode !== 'overwrite_dedup' &&
|
|
112
|
+
cursorField !== undefined)
|
|
113
|
+
throw new TypeError('Full refresh append/overwrite does not use cursorField');
|
|
114
|
+
}
|
|
115
|
+
deduplication() {
|
|
116
|
+
if (this.primaryKey === undefined)
|
|
117
|
+
throw new TypeError('Deduplication requires a primaryKey');
|
|
118
|
+
return new Deduplication(this.stream, this.primaryKey, this.cursorField);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CheckpointStore } from '../state/checkpoint-store.ts';
|
|
2
|
+
import { CopyConfiguration } from './copy-configuration.ts';
|
|
3
|
+
import type { Destination } from './destination.ts';
|
|
4
|
+
import type { Source } from './source.ts';
|
|
5
|
+
import type { Stream } from './stream.ts';
|
|
6
|
+
import { Target as DestinationTarget } from './target.ts';
|
|
7
|
+
import type { LoadFailure, WriteCount } from './writer.ts';
|
|
8
|
+
export type CopyResult<Target extends DestinationTarget> = WriteCount & {
|
|
9
|
+
readonly copy: Copy<Target>;
|
|
10
|
+
};
|
|
11
|
+
export type CopyOutcome<Target extends DestinationTarget> = CopyResult<Target> & {
|
|
12
|
+
readonly failures: readonly LoadFailure[];
|
|
13
|
+
readonly cancelled: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function describeFailures<Target extends DestinationTarget>({ copy, failures, }: CopyOutcome<Target>): string;
|
|
16
|
+
export declare class Copy<Target extends DestinationTarget> {
|
|
17
|
+
readonly configuration: CopyConfiguration;
|
|
18
|
+
readonly to: Target;
|
|
19
|
+
readonly id?: string;
|
|
20
|
+
constructor(from: Stream, to: Target, modes?: ConstructorParameters<typeof CopyConfiguration>[1] & {
|
|
21
|
+
id?: string;
|
|
22
|
+
});
|
|
23
|
+
get from(): Stream;
|
|
24
|
+
writer(source: Source): string;
|
|
25
|
+
validate(source: Source, destination: Destination<Target>, checkpoints?: CheckpointStore): void;
|
|
26
|
+
clear(source: Source, destination: Destination<Target>, checkpoints?: CheckpointStore): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { errorMessage } from '../state/sync-history.js';
|
|
2
|
+
import { CopyConfiguration } from './copy-configuration.js';
|
|
3
|
+
import { FileTransfer } from './file-transfer.js';
|
|
4
|
+
import { Target as DestinationTarget } from './target.js';
|
|
5
|
+
export function describeFailures({ copy, failures, }) {
|
|
6
|
+
return failures
|
|
7
|
+
.map(({ partition, error }) => `${copy.id ?? copy.from.name}${partition === null ? '' : ` ${JSON.stringify(partition)}`}: ${errorMessage(error)}`)
|
|
8
|
+
.join('; ');
|
|
9
|
+
}
|
|
10
|
+
// One configured transfer from a source stream to a destination declaration.
|
|
11
|
+
export class Copy {
|
|
12
|
+
configuration;
|
|
13
|
+
to;
|
|
14
|
+
id;
|
|
15
|
+
constructor(from, to, modes = {
|
|
16
|
+
syncMode: 'full_refresh',
|
|
17
|
+
destinationSyncMode: 'overwrite',
|
|
18
|
+
}) {
|
|
19
|
+
if (!(to instanceof DestinationTarget))
|
|
20
|
+
throw new TypeError('Copy requires a destination target');
|
|
21
|
+
this.configuration = new CopyConfiguration(from, modes, to.fileReads);
|
|
22
|
+
this.to = to;
|
|
23
|
+
if (modes.id !== undefined && (!modes.id || modes.id.includes('\0')))
|
|
24
|
+
throw new TypeError('Copy id must be nonempty text');
|
|
25
|
+
this.id = modes.id;
|
|
26
|
+
Object.freeze(this);
|
|
27
|
+
}
|
|
28
|
+
get from() {
|
|
29
|
+
return this.configuration.stream;
|
|
30
|
+
}
|
|
31
|
+
// The target's writer: the declared id names the replication; without one,
|
|
32
|
+
// the source and stream do.
|
|
33
|
+
writer(source) {
|
|
34
|
+
return JSON.stringify(this.id !== undefined
|
|
35
|
+
? { copy: this.id }
|
|
36
|
+
: { source: source.identity, stream: this.from.name });
|
|
37
|
+
}
|
|
38
|
+
validate(source, destination, checkpoints) {
|
|
39
|
+
source.validate(this.configuration);
|
|
40
|
+
destination.validate(this.configuration, this.to);
|
|
41
|
+
if (this.configuration.syncMode === 'incremental' &&
|
|
42
|
+
(this.id === undefined || checkpoints === undefined))
|
|
43
|
+
throw new TypeError('Incremental copies require a stable id and checkpoint store');
|
|
44
|
+
}
|
|
45
|
+
// Empties the target and removes this copy's checkpoint together, so the
|
|
46
|
+
// next run reloads from scratch.
|
|
47
|
+
async clear(source, destination, checkpoints) {
|
|
48
|
+
this.validate(source, destination, checkpoints);
|
|
49
|
+
const files = new FileTransfer(this.configuration.fileReads, destination.location(this.to), this.writer(source));
|
|
50
|
+
const drop = () => destination.clear(this.configuration, this.to, this.writer(source), (values) => files.reconcile(values));
|
|
51
|
+
if (this.id !== undefined && checkpoints !== undefined)
|
|
52
|
+
await checkpoints.clear(this.id, drop);
|
|
53
|
+
else
|
|
54
|
+
await drop();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type DeclaredFormat } from './formats/declared-format.ts';
|
|
2
|
+
import type { Stream } from './stream.ts';
|
|
3
|
+
export declare class Deduplication {
|
|
4
|
+
#private;
|
|
5
|
+
readonly stream: Stream;
|
|
6
|
+
readonly primaryKey: readonly string[];
|
|
7
|
+
readonly cursorField?: string;
|
|
8
|
+
constructor(stream: Stream, primaryKey: readonly string[], cursorField?: string);
|
|
9
|
+
format(field: string): DeclaredFormat | null;
|
|
10
|
+
type(field: string): 'string' | 'number' | 'integer' | 'boolean';
|
|
11
|
+
value(record: unknown, field: string): string | number | boolean;
|
|
12
|
+
key(record: unknown): string;
|
|
13
|
+
cursor(record: unknown): string | number;
|
|
14
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { declaredFormat, } from './formats/declared-format.js';
|
|
2
|
+
// The selected logical identity and ordering rule, independent of storage constraints.
|
|
3
|
+
export class Deduplication {
|
|
4
|
+
stream;
|
|
5
|
+
primaryKey;
|
|
6
|
+
// Absent when the newest extraction always wins (dedupPolicy replace).
|
|
7
|
+
cursorField;
|
|
8
|
+
#formats = new Map();
|
|
9
|
+
constructor(stream, primaryKey, cursorField) {
|
|
10
|
+
this.stream = stream;
|
|
11
|
+
this.cursorField = cursorField;
|
|
12
|
+
if (!Array.isArray(primaryKey) ||
|
|
13
|
+
primaryKey.length === 0 ||
|
|
14
|
+
new Set(primaryKey).size !== primaryKey.length)
|
|
15
|
+
throw new TypeError('Deduplication requires distinct primaryKey fields');
|
|
16
|
+
this.primaryKey = Object.freeze([...primaryKey]);
|
|
17
|
+
for (const field of this.primaryKey)
|
|
18
|
+
this.#declare(field);
|
|
19
|
+
if (cursorField !== undefined) {
|
|
20
|
+
if (!['string', 'number', 'integer'].includes(this.type(cursorField)))
|
|
21
|
+
throw new TypeError('Deduplication cursor must be text or numeric');
|
|
22
|
+
const format = this.#declare(cursorField);
|
|
23
|
+
if (format?.orderable === false)
|
|
24
|
+
throw new TypeError(`Deduplication cursor ${cursorField} cannot order ${format.name} values`);
|
|
25
|
+
}
|
|
26
|
+
Object.freeze(this);
|
|
27
|
+
}
|
|
28
|
+
#declare(field) {
|
|
29
|
+
this.type(field);
|
|
30
|
+
const schema = this.stream.jsonSchema.properties?.[field];
|
|
31
|
+
const format = schema === undefined ? null : declaredFormat(schema);
|
|
32
|
+
this.#formats.set(field, format);
|
|
33
|
+
return format;
|
|
34
|
+
}
|
|
35
|
+
// The format of a key or cursor field, null when it declares none.
|
|
36
|
+
format(field) {
|
|
37
|
+
const format = this.#formats.get(field);
|
|
38
|
+
if (format === undefined)
|
|
39
|
+
throw new TypeError(`${field} is not a key or cursor field`);
|
|
40
|
+
return format;
|
|
41
|
+
}
|
|
42
|
+
type(field) {
|
|
43
|
+
const properties = this.stream.jsonSchema.properties;
|
|
44
|
+
if (typeof field !== 'string' ||
|
|
45
|
+
!field ||
|
|
46
|
+
properties === null ||
|
|
47
|
+
typeof properties !== 'object' ||
|
|
48
|
+
!Object.hasOwn(properties, field))
|
|
49
|
+
throw new TypeError(`Schema must describe key/cursor field ${field}`);
|
|
50
|
+
const schema = Reflect.get(properties, field);
|
|
51
|
+
const type = schema !== null && typeof schema === 'object'
|
|
52
|
+
? Reflect.get(schema, 'type')
|
|
53
|
+
: undefined;
|
|
54
|
+
if (type !== 'string' &&
|
|
55
|
+
type !== 'number' &&
|
|
56
|
+
type !== 'integer' &&
|
|
57
|
+
type !== 'boolean')
|
|
58
|
+
throw new TypeError(`Key/cursor field ${field} requires one non-null scalar schema type`);
|
|
59
|
+
return type;
|
|
60
|
+
}
|
|
61
|
+
value(record, field) {
|
|
62
|
+
if (record === null ||
|
|
63
|
+
typeof record !== 'object' ||
|
|
64
|
+
!Object.hasOwn(record, field))
|
|
65
|
+
throw new TypeError(`Record is missing key/cursor field ${field}`);
|
|
66
|
+
const value = Reflect.get(record, field);
|
|
67
|
+
const type = this.type(field);
|
|
68
|
+
const format = this.format(field);
|
|
69
|
+
if ((type === 'string' &&
|
|
70
|
+
typeof value === 'string' &&
|
|
71
|
+
value.isWellFormed() &&
|
|
72
|
+
(format === null || format.accepts(value))) ||
|
|
73
|
+
(type === 'boolean' && typeof value === 'boolean') ||
|
|
74
|
+
(type === 'number' &&
|
|
75
|
+
typeof value === 'number' &&
|
|
76
|
+
Number.isFinite(value)) ||
|
|
77
|
+
(type === 'integer' &&
|
|
78
|
+
typeof value === 'number' &&
|
|
79
|
+
Number.isSafeInteger(value)))
|
|
80
|
+
return value;
|
|
81
|
+
throw new TypeError(format === null
|
|
82
|
+
? `Key/cursor field ${field} requires non-null ${type}`
|
|
83
|
+
: `Key/cursor field ${field} requires a canonical ${format.name} value`);
|
|
84
|
+
}
|
|
85
|
+
key(record) {
|
|
86
|
+
return JSON.stringify(this.primaryKey.map((field) => this.value(record, field)));
|
|
87
|
+
}
|
|
88
|
+
cursor(record) {
|
|
89
|
+
if (this.cursorField === undefined)
|
|
90
|
+
throw new TypeError('Deduplication has no cursor field');
|
|
91
|
+
const value = this.value(record, this.cursorField);
|
|
92
|
+
if (typeof value === 'boolean')
|
|
93
|
+
throw new TypeError('Cursor cannot be boolean');
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Connection } from './connection.ts';
|
|
2
|
+
import type { Target as DestinationTarget } from './target.ts';
|
|
3
|
+
export type RefreshTrigger = {
|
|
4
|
+
readonly kind: 'commit';
|
|
5
|
+
readonly tried: Set<string>;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'pass';
|
|
8
|
+
readonly tried: ReadonlySet<string>;
|
|
9
|
+
};
|
|
10
|
+
export declare abstract class DerivedDataset<Target extends DestinationTarget> {
|
|
11
|
+
abstract readonly name: string;
|
|
12
|
+
validate(_connections: readonly Connection<Target>[]): void;
|
|
13
|
+
abstract refresh(options: {
|
|
14
|
+
readonly connections: readonly Connection<Target>[];
|
|
15
|
+
readonly earlier: readonly DerivedDataset<Target>[];
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
readonly trigger: RefreshTrigger;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Data a pipeline keeps in step with what its copies load, and can always
|
|
2
|
+
// rebuild from it (Kleppmann's derived data; Estuary's derivations), such as a
|
|
3
|
+
// view over loaded tables or the text of the files a copy stored. Each kind
|
|
4
|
+
// owns how it makes its data; the pipeline owns when: in the background after
|
|
5
|
+
// each commit of a copy, after each pass of a watch, and after the passes of a
|
|
6
|
+
// run. A refresh can run while a copy loads, so it reads only what committed.
|
|
7
|
+
export class DerivedDataset {
|
|
8
|
+
// Refuses, without I/O, a declaration the pipeline's copies cannot feed.
|
|
9
|
+
validate(_connections) { }
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Connection } from './connection.ts';
|
|
2
|
+
import type { DerivedDataset, RefreshTrigger } from './derived-dataset.ts';
|
|
3
|
+
import type { Target as DestinationTarget } from './target.ts';
|
|
4
|
+
type Kind = RefreshTrigger['kind'];
|
|
5
|
+
export declare class DerivedRefreshes<Target extends DestinationTarget> {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(datasets: readonly DerivedDataset<Target>[], connections: readonly Connection<Target>[], signal: AbortSignal | undefined, stop: (error: unknown) => void);
|
|
8
|
+
get failure(): {
|
|
9
|
+
readonly error: unknown;
|
|
10
|
+
} | undefined;
|
|
11
|
+
request(kind: Kind): void;
|
|
12
|
+
settled(): Promise<void>;
|
|
13
|
+
refresh(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Latch } from '@zukhruf/async';
|
|
2
|
+
// No refresh runs.
|
|
3
|
+
class Idle {
|
|
4
|
+
}
|
|
5
|
+
// A refresh runs; done opens once it ended.
|
|
6
|
+
class Running {
|
|
7
|
+
done;
|
|
8
|
+
constructor(done) {
|
|
9
|
+
this.done = done;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// A refresh runs and a copy asked for one more while it ran: one follows, of
|
|
13
|
+
// the strongest kind asked, since a pass refresh does what a commit refresh
|
|
14
|
+
// does and more.
|
|
15
|
+
class Queued {
|
|
16
|
+
done;
|
|
17
|
+
kind;
|
|
18
|
+
constructor(done, kind) {
|
|
19
|
+
this.done = done;
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// No refresh failed yet.
|
|
24
|
+
class Healthy {
|
|
25
|
+
}
|
|
26
|
+
// The first refresh failure that the watch's own stop did not cause.
|
|
27
|
+
class Failed {
|
|
28
|
+
error;
|
|
29
|
+
constructor(error) {
|
|
30
|
+
this.error = error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// A pipeline's refreshes of its derived datasets. In the background, one
|
|
34
|
+
// refresh runs at a time, and a copy that commits while one runs asks for one
|
|
35
|
+
// more, so no pass waits on them, as a build watcher keeps one rebuild queued
|
|
36
|
+
// and Spark's default trigger starts the next micro-batch once the last one
|
|
37
|
+
// ended. The first background failure is told to stop and starts no more
|
|
38
|
+
// background refreshes; a refresh that signal's abort ended is no failure.
|
|
39
|
+
export class DerivedRefreshes {
|
|
40
|
+
#datasets;
|
|
41
|
+
#connections;
|
|
42
|
+
#signal;
|
|
43
|
+
#stop;
|
|
44
|
+
#state = new Idle();
|
|
45
|
+
// Each dataset's keys that commit refreshes tried since the last pass
|
|
46
|
+
// refresh; the memory of the passes that run, which ends with them.
|
|
47
|
+
#tried = new Map();
|
|
48
|
+
#health = new Healthy();
|
|
49
|
+
constructor(datasets, connections, signal, stop) {
|
|
50
|
+
this.#datasets = datasets;
|
|
51
|
+
this.#connections = connections;
|
|
52
|
+
this.#signal = signal;
|
|
53
|
+
this.#stop = stop;
|
|
54
|
+
}
|
|
55
|
+
get failure() {
|
|
56
|
+
return this.#health instanceof Failed ? this.#health : undefined;
|
|
57
|
+
}
|
|
58
|
+
request(kind) {
|
|
59
|
+
if (this.#datasets.length === 0 || this.#health instanceof Failed)
|
|
60
|
+
return;
|
|
61
|
+
if (this.#state instanceof Idle) {
|
|
62
|
+
const done = new Latch();
|
|
63
|
+
this.#state = new Running(done);
|
|
64
|
+
void this.#run(done, kind);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.#state = new Queued(this.#state.done, this.#state instanceof Queued && this.#state.kind === 'pass'
|
|
68
|
+
? 'pass'
|
|
69
|
+
: kind);
|
|
70
|
+
}
|
|
71
|
+
async settled() {
|
|
72
|
+
if (this.#state instanceof Idle)
|
|
73
|
+
return;
|
|
74
|
+
await this.#state.done.wait();
|
|
75
|
+
}
|
|
76
|
+
// Once the background refresh ended, refreshes every dataset once more for
|
|
77
|
+
// the passes that ended, and rejects with its failure, as the end of a run.
|
|
78
|
+
async refresh() {
|
|
79
|
+
await this.settled();
|
|
80
|
+
await this.#refresh('pass');
|
|
81
|
+
}
|
|
82
|
+
// Every dataset in declared order, each after the ones it can read.
|
|
83
|
+
async #refresh(kind) {
|
|
84
|
+
for (const [index, dataset] of this.#datasets.entries()) {
|
|
85
|
+
const tried = this.#tried.get(dataset) ?? new Set();
|
|
86
|
+
this.#tried.set(dataset, tried);
|
|
87
|
+
await dataset.refresh({
|
|
88
|
+
connections: this.#connections,
|
|
89
|
+
earlier: this.#datasets.slice(0, index),
|
|
90
|
+
signal: this.#signal,
|
|
91
|
+
trigger: { kind, tried },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// The passes it follows ended, and their memory with them.
|
|
95
|
+
if (kind === 'pass')
|
|
96
|
+
this.#tried.clear();
|
|
97
|
+
}
|
|
98
|
+
// Refreshes once, and once more while a copy asked for it.
|
|
99
|
+
async #run(done, first) {
|
|
100
|
+
try {
|
|
101
|
+
for (let kind = first;;) {
|
|
102
|
+
await this.#refresh(kind);
|
|
103
|
+
if (!(this.#state instanceof Queued) || this.#signal?.aborted)
|
|
104
|
+
break;
|
|
105
|
+
kind = this.#state.kind;
|
|
106
|
+
this.#state = new Running(done);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (this.#signal?.aborted !== true) {
|
|
111
|
+
this.#health = new Failed(error);
|
|
112
|
+
this.#stop(error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
this.#state = new Idle();
|
|
117
|
+
done.open();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PipelineClock } from './clock.ts';
|
|
2
|
+
import type { CopyConfiguration } from './copy-configuration.ts';
|
|
3
|
+
import type { Target as DestinationTarget } from './target.ts';
|
|
4
|
+
import type { FieldValues, Stage, Writer } from './writer.ts';
|
|
5
|
+
export type DestinationSyncMode = 'append' | 'overwrite' | 'append_dedup' | 'overwrite_dedup';
|
|
6
|
+
export type Load<Target extends DestinationTarget> = AsyncDisposable & {
|
|
7
|
+
prepare(configuration: CopyConfiguration, target: Target, binding: {
|
|
8
|
+
readonly writer: string;
|
|
9
|
+
readonly reloading: boolean;
|
|
10
|
+
}): Promise<Stage>;
|
|
11
|
+
};
|
|
12
|
+
export declare abstract class Destination<Target extends DestinationTarget> {
|
|
13
|
+
abstract readonly supportedDestinationSyncModes: readonly DestinationSyncMode[];
|
|
14
|
+
abstract location(target: Target): string;
|
|
15
|
+
validate(configuration: CopyConfiguration, target: Target): void;
|
|
16
|
+
protected validateConfiguration(configuration: CopyConfiguration, target: Target): void;
|
|
17
|
+
abstract createWriter(configuration: CopyConfiguration, target: Target): Writer;
|
|
18
|
+
abstract load(options: {
|
|
19
|
+
clock: PipelineClock;
|
|
20
|
+
}): Promise<Load<Target>>;
|
|
21
|
+
clear(configuration: CopyConfiguration, target: Target, writer: string, committed?: (values: FieldValues) => Promise<void>): Promise<void>;
|
|
22
|
+
}
|