@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,128 @@
|
|
|
1
|
+
import { declaredFormat, } from './formats/declared-format.js';
|
|
2
|
+
const itemTypes = new Set(['string', 'integer', 'number', 'boolean']);
|
|
3
|
+
const scalarTypes = new Set([...itemTypes, 'null']);
|
|
4
|
+
const typesOf = (field) => typeof field.type === 'string' ? [field.type] : field.type;
|
|
5
|
+
const isArrayField = (field) => typesOf(field).includes('array');
|
|
6
|
+
// Whether a scalar declares only scalar types.
|
|
7
|
+
function scalarSupported(field) {
|
|
8
|
+
const { type } = field;
|
|
9
|
+
const types = Array.isArray(type) ? type : [type];
|
|
10
|
+
return (field.items === undefined && types.every((each) => scalarTypes.has(each)));
|
|
11
|
+
}
|
|
12
|
+
// Whether an object item declares scalar fields and names each one required.
|
|
13
|
+
function objectSupported(items) {
|
|
14
|
+
const { properties, required } = items;
|
|
15
|
+
if (properties === null || typeof properties !== 'object')
|
|
16
|
+
return false;
|
|
17
|
+
const names = Object.keys(properties);
|
|
18
|
+
return (names.length > 0 &&
|
|
19
|
+
Array.isArray(required) &&
|
|
20
|
+
required.length === names.length &&
|
|
21
|
+
names.every((name) => required.includes(name)) &&
|
|
22
|
+
Object.values(properties).every(scalarSupported));
|
|
23
|
+
}
|
|
24
|
+
// Whether a field declares a type validateRecords can check: scalar types, or
|
|
25
|
+
// an array (alone or with null) of one non-null scalar type or of objects of
|
|
26
|
+
// scalar fields.
|
|
27
|
+
function supported(field) {
|
|
28
|
+
if (!isArrayField(field))
|
|
29
|
+
return scalarSupported(field);
|
|
30
|
+
const types = typesOf(field);
|
|
31
|
+
const { items } = field;
|
|
32
|
+
return (types.every((type) => type === 'array' || type === 'null') &&
|
|
33
|
+
// Constraints on an array field belong on its items.
|
|
34
|
+
field.enum === undefined &&
|
|
35
|
+
field.format === undefined &&
|
|
36
|
+
field.precision === undefined &&
|
|
37
|
+
field.scale === undefined &&
|
|
38
|
+
field.contentEncoding === undefined &&
|
|
39
|
+
field.minimum === undefined &&
|
|
40
|
+
field.maximum === undefined &&
|
|
41
|
+
field.minLength === undefined &&
|
|
42
|
+
items !== null &&
|
|
43
|
+
typeof items === 'object' &&
|
|
44
|
+
(items.type === 'object'
|
|
45
|
+
? objectSupported(items)
|
|
46
|
+
: itemTypes.has(items.type)));
|
|
47
|
+
}
|
|
48
|
+
// Whether a non-null value satisfies a scalar schema's type and constraints.
|
|
49
|
+
function scalarValid(field, types, format, value) {
|
|
50
|
+
const typed = types.some((type) => type === 'integer'
|
|
51
|
+
? Number.isSafeInteger(value)
|
|
52
|
+
: type === 'number'
|
|
53
|
+
? typeof value === 'number' && Number.isFinite(value)
|
|
54
|
+
: (type === 'string' || type === 'boolean') && typeof value === type);
|
|
55
|
+
return (typed &&
|
|
56
|
+
(field.enum === undefined ||
|
|
57
|
+
field.enum.some((member) => member === value)) &&
|
|
58
|
+
!(typeof value === 'number' &&
|
|
59
|
+
((field.minimum !== undefined && value < field.minimum) ||
|
|
60
|
+
(field.maximum !== undefined && value > field.maximum))) &&
|
|
61
|
+
!(typeof value === 'string' &&
|
|
62
|
+
field.minLength !== undefined &&
|
|
63
|
+
value.length < field.minLength) &&
|
|
64
|
+
(format === null || (typeof value === 'string' && format.accepts(value))));
|
|
65
|
+
}
|
|
66
|
+
// A check of one scalar value against its schema, nulls included when the
|
|
67
|
+
// schema allows them; the format is read once.
|
|
68
|
+
function scalarCheck(field) {
|
|
69
|
+
const types = typesOf(field);
|
|
70
|
+
const format = declaredFormat(field);
|
|
71
|
+
return (value) => (value === null && types.includes('null')) ||
|
|
72
|
+
scalarValid(field, types, format, value);
|
|
73
|
+
}
|
|
74
|
+
// A check of one item of an array field.
|
|
75
|
+
function itemCheck(items) {
|
|
76
|
+
if (items.type !== 'object')
|
|
77
|
+
return scalarCheck(items);
|
|
78
|
+
const fields = Object.entries(items.properties).map(([name, field]) => [name, scalarCheck(field)]);
|
|
79
|
+
return (item) => item !== null &&
|
|
80
|
+
typeof item === 'object' &&
|
|
81
|
+
!Array.isArray(item) &&
|
|
82
|
+
Object.keys(item).length === fields.length &&
|
|
83
|
+
fields.every(([name, valid]) => Object.hasOwn(item, name) && valid(Reflect.get(item, name)));
|
|
84
|
+
}
|
|
85
|
+
// A check of one field: a scalar, or an array whose every item is valid.
|
|
86
|
+
function fieldCheck(field) {
|
|
87
|
+
if (!isArrayField(field))
|
|
88
|
+
return scalarCheck(field);
|
|
89
|
+
const item = itemCheck(field.items);
|
|
90
|
+
const nullable = typesOf(field).includes('null');
|
|
91
|
+
return (value) => (value === null && nullable) || (Array.isArray(value) && value.every(item));
|
|
92
|
+
}
|
|
93
|
+
// Every property is required and a record carries exactly those properties, so
|
|
94
|
+
// a projection that drops or misspells a field fails here instead of loading.
|
|
95
|
+
export function validateRecords(stream, records, source) {
|
|
96
|
+
if (!Array.isArray(records))
|
|
97
|
+
throw new TypeError(`${source} returned invalid ${stream.name} records`);
|
|
98
|
+
const { properties } = stream.jsonSchema;
|
|
99
|
+
if (properties === undefined)
|
|
100
|
+
throw new TypeError(`Stream ${stream.name} declares no properties to validate`);
|
|
101
|
+
const fields = Object.entries(properties).map(([name, schema]) => {
|
|
102
|
+
const unsupported = (cause) => new TypeError(`Stream ${stream.name}.${name} declares an unsupported type`, { cause });
|
|
103
|
+
if (!supported(schema))
|
|
104
|
+
throw unsupported();
|
|
105
|
+
try {
|
|
106
|
+
return { name, valid: fieldCheck(schema) };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw unsupported(error);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const valid = [];
|
|
113
|
+
for (const record of records) {
|
|
114
|
+
assertRecord(record, fields, stream.name, source);
|
|
115
|
+
valid.push(record);
|
|
116
|
+
}
|
|
117
|
+
return valid;
|
|
118
|
+
}
|
|
119
|
+
function assertRecord(record, fields, stream, source) {
|
|
120
|
+
if (record === null ||
|
|
121
|
+
typeof record !== 'object' ||
|
|
122
|
+
Array.isArray(record) ||
|
|
123
|
+
Object.keys(record).length !== fields.length)
|
|
124
|
+
throw new TypeError(`${source} returned an invalid ${stream} record`);
|
|
125
|
+
for (const { name, valid } of fields)
|
|
126
|
+
if (!valid(Reflect.get(record, name)))
|
|
127
|
+
throw new TypeError(`${source} returned invalid ${stream}.${name}`);
|
|
128
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DestinationSyncMode } from './destination.ts';
|
|
2
|
+
export type StoredFit = 'missing' | 'stale' | 'fits';
|
|
3
|
+
export type ReloadMode = 'load' | 'create' | 'evolve' | 'reload' | 'continue';
|
|
4
|
+
export declare function reloadMode({ reloading, destinationSyncMode, target, hidden, }: {
|
|
5
|
+
readonly reloading: boolean;
|
|
6
|
+
readonly destinationSyncMode: DestinationSyncMode;
|
|
7
|
+
readonly target: StoredFit;
|
|
8
|
+
readonly hidden: StoredFit;
|
|
9
|
+
}): ReloadMode;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Every destination decides alike: an open reload continues into a hidden
|
|
2
|
+
// target that still fits, or starts over in a new one, and a stored target
|
|
3
|
+
// that no longer fits the stream evolves to it. An overwrite keeps none of
|
|
4
|
+
// the stored rows, so it reloads that target instead, as Airbyte's truncate
|
|
5
|
+
// syncs load a new table and swap it in.
|
|
6
|
+
export function reloadMode({ reloading, destinationSyncMode, target, hidden, }) {
|
|
7
|
+
if (reloading)
|
|
8
|
+
return hidden === 'fits' ? 'continue' : 'reload';
|
|
9
|
+
if (target === 'missing')
|
|
10
|
+
return 'create';
|
|
11
|
+
if (target === 'stale') {
|
|
12
|
+
const overwrites = destinationSyncMode === 'overwrite' ||
|
|
13
|
+
destinationSyncMode === 'overwrite_dedup';
|
|
14
|
+
return overwrites ? 'reload' : 'evolve';
|
|
15
|
+
}
|
|
16
|
+
return 'load';
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CheckpointStore } from '../state/checkpoint-store.ts';
|
|
2
|
+
import type { PipelineClock } from './clock.ts';
|
|
3
|
+
import type { Connection } from './connection.ts';
|
|
4
|
+
import type { Copy, CopyOutcome } from './copy.ts';
|
|
5
|
+
import type { Destination } from './destination.ts';
|
|
6
|
+
import { type Source } from './source.ts';
|
|
7
|
+
import type { Target as DestinationTarget } from './target.ts';
|
|
8
|
+
export declare function replicate<Target extends DestinationTarget>(source: Source, destination: Destination<Target>, checkpoints: CheckpointStore | undefined, copies: readonly Copy<Target>[], connection: Connection<Target>, committed: () => void, signal: AbortSignal | undefined, clock: PipelineClock): Promise<CopyOutcome<Target>[]>;
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { FileTransfer } from './file-transfer.js';
|
|
2
|
+
import { CopyProgress, passChannels } from './pass-channels.js';
|
|
3
|
+
import { StreamStatus, } from './source.js';
|
|
4
|
+
// A copy that has not reported how it ended.
|
|
5
|
+
class Reporting {
|
|
6
|
+
}
|
|
7
|
+
// A copy that reported how it ended, once.
|
|
8
|
+
class Settled {
|
|
9
|
+
}
|
|
10
|
+
// One copy's side of a replication: its stage, what it committed, what is
|
|
11
|
+
// pending since the last commit, and what did not load.
|
|
12
|
+
class Replicated {
|
|
13
|
+
stage;
|
|
14
|
+
emitted = { count: 0, deleted: 0 };
|
|
15
|
+
committed = { count: 0, deleted: 0 };
|
|
16
|
+
pending = { count: 0, deleted: 0 };
|
|
17
|
+
failures = [];
|
|
18
|
+
// Nothing commits after a failed read until the next checkpoint: a full
|
|
19
|
+
// refresh has none, so any failure keeps the previous target.
|
|
20
|
+
failed = false;
|
|
21
|
+
// Nothing was applied since the last commit.
|
|
22
|
+
clean = false;
|
|
23
|
+
// The stage failed; the stream's remaining messages are ignored.
|
|
24
|
+
broken = false;
|
|
25
|
+
started = false;
|
|
26
|
+
ended = false;
|
|
27
|
+
// The copy's last run left a reload open.
|
|
28
|
+
reloading = false;
|
|
29
|
+
// The run's signal stopped the copy before it ended.
|
|
30
|
+
cancelled = false;
|
|
31
|
+
// Whether the saved checkpoint describes the copy's stage: not once the
|
|
32
|
+
// stage is a target this load creates or reloads, until the copy saves.
|
|
33
|
+
resumes = true;
|
|
34
|
+
#progress = new Reporting();
|
|
35
|
+
copy;
|
|
36
|
+
connection;
|
|
37
|
+
// Told each time the copy made rows visible in its target.
|
|
38
|
+
published;
|
|
39
|
+
files;
|
|
40
|
+
#source;
|
|
41
|
+
constructor(copy, connection, source, destination, published) {
|
|
42
|
+
this.copy = copy;
|
|
43
|
+
this.connection = connection;
|
|
44
|
+
this.published = published;
|
|
45
|
+
this.#source = source;
|
|
46
|
+
this.files = new FileTransfer(copy.configuration.fileReads, destination.location(copy.to), copy.writer(source));
|
|
47
|
+
}
|
|
48
|
+
get stream() {
|
|
49
|
+
return this.copy.from;
|
|
50
|
+
}
|
|
51
|
+
// What did not load, of the kind the source says the error is.
|
|
52
|
+
recordFailure(partition, error) {
|
|
53
|
+
this.failures.push({
|
|
54
|
+
partition,
|
|
55
|
+
error,
|
|
56
|
+
failureType: this.#source.failureType(error),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
outcome() {
|
|
60
|
+
return {
|
|
61
|
+
copy: this.copy,
|
|
62
|
+
...this.committed,
|
|
63
|
+
failures: this.failures,
|
|
64
|
+
cancelled: this.cancelled,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
report() {
|
|
68
|
+
this.#notify('running');
|
|
69
|
+
}
|
|
70
|
+
settle() {
|
|
71
|
+
if (this.#progress instanceof Settled)
|
|
72
|
+
return;
|
|
73
|
+
this.#progress = new Settled();
|
|
74
|
+
this.#notify(this.failures.length === 0 ? 'complete' : 'incomplete');
|
|
75
|
+
}
|
|
76
|
+
#notify(status) {
|
|
77
|
+
passChannels.progress.publish(new CopyProgress(this.connection, this.copy, status, { ...this.emitted }, { ...this.committed }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Airbyte's replication worker: one read covers every copy's stream, each
|
|
81
|
+
// stream's messages go to its own stage, and a checkpoint is saved only after
|
|
82
|
+
// its stage committed. A copy that fails does not stop the others. Once signal
|
|
83
|
+
// aborts, as Airbyte's worker stops a cancelled sync, no further message is
|
|
84
|
+
// applied: each copy still open keeps what it committed and is cancelled.
|
|
85
|
+
// committed is told each time a copy made rows visible in its target: at
|
|
86
|
+
// each commit, and when a reload replaced the target.
|
|
87
|
+
export async function replicate(source, destination, checkpoints, copies, connection, committed, signal, clock) {
|
|
88
|
+
const replications = copies.map((copy) => new Replicated(copy, connection, source, destination, committed));
|
|
89
|
+
const bindings = new Map();
|
|
90
|
+
for (const { copy } of replications)
|
|
91
|
+
if (copy.configuration.syncMode === 'incremental' && copy.id !== undefined)
|
|
92
|
+
bindings.set(copy.id, {
|
|
93
|
+
copy: {
|
|
94
|
+
source: source.identity,
|
|
95
|
+
target: destination.location(copy.to),
|
|
96
|
+
selection: selection(copy.configuration),
|
|
97
|
+
},
|
|
98
|
+
shape: shape(copy.from),
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
if (checkpoints === undefined || bindings.size === 0)
|
|
102
|
+
await transfer(source, destination, null, replications, signal, clock);
|
|
103
|
+
else
|
|
104
|
+
await checkpoints.run(bindings, (run) => transfer(source, destination, run, replications, signal, clock));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
for (const replication of replications)
|
|
108
|
+
if (!replication.ended) {
|
|
109
|
+
replication.cancelled = cancelledBy(signal, error);
|
|
110
|
+
fail(replication, error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// A broken stream the source never ended settles here, after the read.
|
|
114
|
+
for (const replication of replications)
|
|
115
|
+
replication.settle();
|
|
116
|
+
return replications.map((replication) => replication.outcome());
|
|
117
|
+
}
|
|
118
|
+
async function transfer(source, destination, run, replications, signal, clock) {
|
|
119
|
+
const byStream = new Map(replications.map((replication) => [replication.stream.name, replication]));
|
|
120
|
+
const incremental = (replication) => replication.copy.configuration.syncMode === 'incremental';
|
|
121
|
+
const checkpoint = (replication) => {
|
|
122
|
+
if (run === null || replication.copy.id === undefined)
|
|
123
|
+
throw new TypeError('Incremental copies require a stable id and checkpoint store');
|
|
124
|
+
return { run, id: replication.copy.id };
|
|
125
|
+
};
|
|
126
|
+
// The source asks for a stream's state when it starts reading that stream.
|
|
127
|
+
const states = {
|
|
128
|
+
get: async (name) => {
|
|
129
|
+
const replication = byStream.get(name);
|
|
130
|
+
if (replication === undefined ||
|
|
131
|
+
!incremental(replication) ||
|
|
132
|
+
!replication.resumes)
|
|
133
|
+
return null;
|
|
134
|
+
const { run, id } = checkpoint(replication);
|
|
135
|
+
return run.state(id);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
const reading = [];
|
|
139
|
+
for (const replication of replications) {
|
|
140
|
+
if (!incremental(replication)) {
|
|
141
|
+
reading.push(replication);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const { run, id } = checkpoint(replication);
|
|
146
|
+
replication.reloading = run.reloading(id);
|
|
147
|
+
reading.push(replication);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
fail(replication, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (reading.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
await using load = await destination.load({ clock });
|
|
157
|
+
// Every target is prepared before anything is read; a copy whose target is
|
|
158
|
+
// refused reads nothing.
|
|
159
|
+
const prepared = [];
|
|
160
|
+
for (const replication of reading)
|
|
161
|
+
try {
|
|
162
|
+
replication.stage = await load.prepare(replication.copy.configuration, replication.copy.to, {
|
|
163
|
+
writer: replication.copy.writer(source),
|
|
164
|
+
reloading: replication.reloading,
|
|
165
|
+
});
|
|
166
|
+
// A target this load creates or reloads holds nothing to resume from.
|
|
167
|
+
if (replication.stage.fresh)
|
|
168
|
+
replication.resumes = false;
|
|
169
|
+
await replication.files.reconcile(replication.stage.values);
|
|
170
|
+
prepared.push(replication);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (replication.stage !== undefined)
|
|
174
|
+
await breakStage(replication, error);
|
|
175
|
+
else
|
|
176
|
+
fail(replication, error);
|
|
177
|
+
}
|
|
178
|
+
if (prepared.length === 0)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
for await (const message of source.read(prepared.map(({ copy }) => copy.configuration), states, { signal, clock })) {
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
const replication = byStream.get(validStream(message));
|
|
184
|
+
if (replication === undefined || !prepared.includes(replication))
|
|
185
|
+
throw new TypeError(`Source emitted an unselected stream: ${message.stream}`);
|
|
186
|
+
if (replication.ended)
|
|
187
|
+
throw new TypeError(`Source emitted ${message.stream} after it ended`);
|
|
188
|
+
try {
|
|
189
|
+
if (message instanceof StreamStatus) {
|
|
190
|
+
if (message.status === 'STARTED') {
|
|
191
|
+
if (replication.started)
|
|
192
|
+
throw new TypeError(`Source started ${message.stream} twice`);
|
|
193
|
+
replication.started = true;
|
|
194
|
+
replication.report();
|
|
195
|
+
}
|
|
196
|
+
else if (message.status === 'FAILED') {
|
|
197
|
+
if (replication.broken)
|
|
198
|
+
continue;
|
|
199
|
+
await started(replication).discard();
|
|
200
|
+
replication.pending.count = 0;
|
|
201
|
+
replication.pending.deleted = 0;
|
|
202
|
+
replication.failed = true;
|
|
203
|
+
replication.recordFailure(message.partition, message.error);
|
|
204
|
+
await replication.files.reconcile(started(replication).values);
|
|
205
|
+
replication.report();
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
replication.ended = true;
|
|
209
|
+
if (replication.broken) {
|
|
210
|
+
replication.settle();
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const stage = started(replication);
|
|
214
|
+
// The first commit also makes the prepared target itself durable,
|
|
215
|
+
// so an empty overwrite still clears it.
|
|
216
|
+
if (!replication.failed && !replication.clean)
|
|
217
|
+
await commit(replication, signal);
|
|
218
|
+
// A reload replaces the target only once every partition read.
|
|
219
|
+
if (replication.failures.length === 0 && stage.reloading) {
|
|
220
|
+
await stage.complete();
|
|
221
|
+
replication.published();
|
|
222
|
+
if (incremental(replication)) {
|
|
223
|
+
const { run, id } = checkpoint(replication);
|
|
224
|
+
if (replication.resumes)
|
|
225
|
+
await run.reloaded(id);
|
|
226
|
+
else
|
|
227
|
+
await run.save(id, 'null', false);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
replication.stage = undefined;
|
|
231
|
+
await stage[Symbol.asyncDispose]();
|
|
232
|
+
replication.settle();
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (replication.broken)
|
|
237
|
+
continue;
|
|
238
|
+
const operation = validOperation(replication.stream, message);
|
|
239
|
+
if (operation.type === 'STATE') {
|
|
240
|
+
if (!incremental(replication))
|
|
241
|
+
throw new TypeError(`Full refresh stream ${message.stream} emitted a checkpoint`);
|
|
242
|
+
await commit(replication, signal);
|
|
243
|
+
const { run, id } = checkpoint(replication);
|
|
244
|
+
await run.save(id, operation.state, started(replication).reloading);
|
|
245
|
+
replication.resumes = true;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
await started(replication).apply(operation.type === 'RECORD'
|
|
249
|
+
? {
|
|
250
|
+
...operation,
|
|
251
|
+
data: await replication.files.record(operation.data),
|
|
252
|
+
}
|
|
253
|
+
: operation);
|
|
254
|
+
replication.clean = false;
|
|
255
|
+
if (operation.type === 'RECORD') {
|
|
256
|
+
replication.pending.count++;
|
|
257
|
+
replication.emitted.count++;
|
|
258
|
+
}
|
|
259
|
+
else if (operation.type === 'DELETE') {
|
|
260
|
+
replication.pending.deleted++;
|
|
261
|
+
replication.emitted.deleted++;
|
|
262
|
+
}
|
|
263
|
+
replication.report();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
replication.cancelled = cancelledBy(signal, error);
|
|
268
|
+
await breakStage(replication, error);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const replication of prepared)
|
|
272
|
+
if (!replication.ended && !replication.broken)
|
|
273
|
+
await breakStage(replication, new TypeError(`Source did not end stream ${replication.stream.name}`));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
for (const replication of prepared)
|
|
277
|
+
if (!replication.ended && !replication.broken) {
|
|
278
|
+
replication.cancelled = cancelledBy(signal, error);
|
|
279
|
+
await breakStage(replication, error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Whether error is the abort of the run's signal, not a failure.
|
|
284
|
+
function cancelledBy(signal, error) {
|
|
285
|
+
return signal?.aborted === true && error === signal.reason;
|
|
286
|
+
}
|
|
287
|
+
// What a copy selects of its stream, apart from the stream's shape: changing
|
|
288
|
+
// it refuses the checkpoint. A key the stream declares is part of its shape.
|
|
289
|
+
function selection(configuration) {
|
|
290
|
+
const { stream, fileReads, syncMode, destinationSyncMode, cursorField, primaryKey, dedupPolicy, } = configuration;
|
|
291
|
+
return {
|
|
292
|
+
fileReads,
|
|
293
|
+
syncMode,
|
|
294
|
+
destinationSyncMode,
|
|
295
|
+
cursorField,
|
|
296
|
+
dedupPolicy,
|
|
297
|
+
primaryKey: stream.primaryKey.length > 0 ? undefined : primaryKey,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// The stream as it shapes the target, without the descriptions that only
|
|
301
|
+
// annotate it. A changed field evolves the target; a changed declaration
|
|
302
|
+
// stops the copy (see stream-change.ts).
|
|
303
|
+
function shape(stream) {
|
|
304
|
+
const { description: _, properties, ...schema } = stream.jsonSchema;
|
|
305
|
+
const undescribed = ({ description: __, ...rest }) => rest;
|
|
306
|
+
return {
|
|
307
|
+
...stream,
|
|
308
|
+
jsonSchema: {
|
|
309
|
+
...schema,
|
|
310
|
+
properties: properties &&
|
|
311
|
+
Object.fromEntries(Object.entries(properties).map(([name, { items, ...field }]) => [
|
|
312
|
+
name,
|
|
313
|
+
{ ...undescribed(field), items: items && undescribed(items) },
|
|
314
|
+
])),
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function started(replication) {
|
|
319
|
+
if (!replication.started || replication.stage === undefined)
|
|
320
|
+
throw new TypeError(`Source sent ${replication.stream.name} a message before starting it`);
|
|
321
|
+
return replication.stage;
|
|
322
|
+
}
|
|
323
|
+
async function commit(replication, signal) {
|
|
324
|
+
await replication.files.flush();
|
|
325
|
+
signal?.throwIfAborted();
|
|
326
|
+
await started(replication).commit();
|
|
327
|
+
replication.clean = true;
|
|
328
|
+
replication.failed = false;
|
|
329
|
+
replication.committed.count += replication.pending.count;
|
|
330
|
+
replication.committed.deleted += replication.pending.deleted;
|
|
331
|
+
replication.pending.count = 0;
|
|
332
|
+
replication.pending.deleted = 0;
|
|
333
|
+
await replication.files.reconcile(started(replication).values);
|
|
334
|
+
// The commit ends once its stored files are reconciled: a refresh that
|
|
335
|
+
// starts earlier could read a file the reconcile still needs.
|
|
336
|
+
replication.published();
|
|
337
|
+
replication.report();
|
|
338
|
+
}
|
|
339
|
+
function fail(replication, error) {
|
|
340
|
+
replication.recordFailure(null, error);
|
|
341
|
+
replication.broken = true;
|
|
342
|
+
replication.ended = true;
|
|
343
|
+
replication.settle();
|
|
344
|
+
}
|
|
345
|
+
// Fails a stream whose stage or checkpoint failed: its uncommitted rows are
|
|
346
|
+
// dropped with the stage, what it committed before stays, and its later
|
|
347
|
+
// messages are ignored until the source ends it.
|
|
348
|
+
async function breakStage(replication, error) {
|
|
349
|
+
replication.recordFailure(null, error);
|
|
350
|
+
replication.broken = true;
|
|
351
|
+
const { stage } = replication;
|
|
352
|
+
replication.stage = undefined;
|
|
353
|
+
try {
|
|
354
|
+
await stage?.[Symbol.asyncDispose]();
|
|
355
|
+
}
|
|
356
|
+
catch (cause) {
|
|
357
|
+
replication.recordFailure(null, cause);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function validStream(message) {
|
|
361
|
+
if (message === null ||
|
|
362
|
+
typeof message !== 'object' ||
|
|
363
|
+
typeof message.stream !== 'string')
|
|
364
|
+
throw new TypeError('Source must emit records with stream and data, DELETE, RESET or STATE messages');
|
|
365
|
+
return message.stream;
|
|
366
|
+
}
|
|
367
|
+
// A STATE's state is checked and kept as the JSON text it is saved as.
|
|
368
|
+
function validOperation(stream, message) {
|
|
369
|
+
if ('type' in message &&
|
|
370
|
+
message.type === 'STATE' &&
|
|
371
|
+
Object.hasOwn(message, 'state') &&
|
|
372
|
+
!Object.hasOwn(message, 'data')) {
|
|
373
|
+
if (!lossless(message.state))
|
|
374
|
+
throw new TypeError('Checkpoint state must be losslessly JSON serializable');
|
|
375
|
+
wellFormed(message.state, 'Checkpoint state');
|
|
376
|
+
return { type: 'STATE', state: JSON.stringify(message.state) };
|
|
377
|
+
}
|
|
378
|
+
if ('type' in message &&
|
|
379
|
+
message.type === 'DELETE' &&
|
|
380
|
+
Object.hasOwn(message, 'key') &&
|
|
381
|
+
!Object.hasOwn(message, 'data'))
|
|
382
|
+
return { type: 'DELETE', key: deletionKey(stream, message.key) };
|
|
383
|
+
if ('type' in message &&
|
|
384
|
+
message.type === 'RESET' &&
|
|
385
|
+
Object.hasOwn(message, 'partition') &&
|
|
386
|
+
!Object.hasOwn(message, 'data')) {
|
|
387
|
+
if (!stream.emitsDeletes)
|
|
388
|
+
throw new TypeError(`Stream ${stream.name} does not emit deletions, so it cannot reset`);
|
|
389
|
+
if ((message.partition === null) !== (stream.partitionKey === undefined))
|
|
390
|
+
throw new TypeError(`A reset of ${stream.name} is scoped to a partition exactly when the stream is partitioned`);
|
|
391
|
+
return { type: 'RESET', partition: message.partition };
|
|
392
|
+
}
|
|
393
|
+
if (!('type' in message) &&
|
|
394
|
+
'data' in message &&
|
|
395
|
+
Object.hasOwn(message, 'data')) {
|
|
396
|
+
if (Object.hasOwn(message, 'file'))
|
|
397
|
+
throw new TypeError('Destinations cannot receive source file paths');
|
|
398
|
+
wellFormed(message.data, 'Record');
|
|
399
|
+
return { type: 'RECORD', data: message.data };
|
|
400
|
+
}
|
|
401
|
+
throw new TypeError('Source must emit records with stream and data, DELETE, RESET or STATE messages');
|
|
402
|
+
}
|
|
403
|
+
// Whether JSON carries value exactly, as JSON.parse(JSON.stringify(value))
|
|
404
|
+
// strictly deep-equals it: null, a boolean, a string, a finite number other
|
|
405
|
+
// than -0, an array with every index and no other field, or an object of
|
|
406
|
+
// Object.prototype, none with an enumerable symbol key. A walk, not a round
|
|
407
|
+
// trip, so checking a state as large as a snapshot copies none of it.
|
|
408
|
+
function lossless(value) {
|
|
409
|
+
switch (typeof value) {
|
|
410
|
+
case 'boolean':
|
|
411
|
+
case 'string':
|
|
412
|
+
return true;
|
|
413
|
+
case 'number':
|
|
414
|
+
return Number.isFinite(value) && !Object.is(value, -0);
|
|
415
|
+
case 'object': {
|
|
416
|
+
if (value === null)
|
|
417
|
+
return true;
|
|
418
|
+
if (Object.getOwnPropertySymbols(value).some((key) => Object.prototype.propertyIsEnumerable.call(value, key)))
|
|
419
|
+
return false;
|
|
420
|
+
const fields = Object.keys(value);
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
if (Object.getPrototypeOf(value) !== Array.prototype ||
|
|
423
|
+
fields.length !== value.length)
|
|
424
|
+
return false;
|
|
425
|
+
for (let index = 0; index < value.length; index++)
|
|
426
|
+
if (!Object.hasOwn(value, index) || !lossless(value[index]))
|
|
427
|
+
return false;
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return (Object.getPrototypeOf(value) === Object.prototype &&
|
|
431
|
+
fields.every((field) => lossless(Reflect.get(value, field))));
|
|
432
|
+
}
|
|
433
|
+
default:
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Every store keeps text as Unicode: a lone surrogate would load as U+FFFD
|
|
438
|
+
// (SQLite) or be refused mid-load (Postgres), so no text reaches one.
|
|
439
|
+
function wellFormed(value, path) {
|
|
440
|
+
if (typeof value === 'string') {
|
|
441
|
+
if (!value.isWellFormed())
|
|
442
|
+
throw new TypeError(`${path} has a lone surrogate`);
|
|
443
|
+
}
|
|
444
|
+
else if (Array.isArray(value))
|
|
445
|
+
for (const [index, item] of value.entries())
|
|
446
|
+
wellFormed(item, `${path}[${index}]`);
|
|
447
|
+
else if (value !== null &&
|
|
448
|
+
typeof value === 'object' &&
|
|
449
|
+
(Object.getPrototypeOf(value) === Object.prototype ||
|
|
450
|
+
Object.getPrototypeOf(value) === null))
|
|
451
|
+
for (const [name, item] of Object.entries(value)) {
|
|
452
|
+
if (!name.isWellFormed())
|
|
453
|
+
throw new TypeError(`${path} has a field name with a lone surrogate`);
|
|
454
|
+
wellFormed(item, `${path} field ${name}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function deletionKey(stream, key) {
|
|
458
|
+
const { name, primaryKey, emitsDeletes } = stream;
|
|
459
|
+
if (!emitsDeletes)
|
|
460
|
+
throw new TypeError(`Stream ${name} does not emit deletions`);
|
|
461
|
+
if (key === null ||
|
|
462
|
+
typeof key !== 'object' ||
|
|
463
|
+
Array.isArray(key) ||
|
|
464
|
+
Object.getPrototypeOf(key) !== Object.prototype ||
|
|
465
|
+
Object.keys(key).length !== primaryKey.length ||
|
|
466
|
+
!primaryKey.every((field) => Object.hasOwn(key, field)))
|
|
467
|
+
throw new TypeError(`DELETE for ${name} must carry exactly its primary key ${JSON.stringify(primaryKey)}`);
|
|
468
|
+
const snapshot = {};
|
|
469
|
+
for (const field of primaryKey) {
|
|
470
|
+
const value = Reflect.get(key, field);
|
|
471
|
+
if (!(typeof value === 'string' && value.isWellFormed()) &&
|
|
472
|
+
!(typeof value === 'number' && Number.isFinite(value)) &&
|
|
473
|
+
typeof value !== 'boolean')
|
|
474
|
+
throw new TypeError(`DELETE for ${name} has an invalid ${field}`);
|
|
475
|
+
snapshot[field] = value;
|
|
476
|
+
}
|
|
477
|
+
return Object.freeze(snapshot);
|
|
478
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type LoadedRow = Readonly<Record<string, unknown>>;
|
|
2
|
+
export type RuleResult<Output> = {
|
|
3
|
+
readonly value: Output;
|
|
4
|
+
} | {
|
|
5
|
+
readonly empty: true;
|
|
6
|
+
} | {
|
|
7
|
+
readonly error: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly gone: true;
|
|
10
|
+
};
|
|
11
|
+
export type RuleColumn = {
|
|
12
|
+
readonly description: string;
|
|
13
|
+
readonly type: 'text' | 'blob';
|
|
14
|
+
};
|
|
15
|
+
export declare abstract class Rule<Input, Output> {
|
|
16
|
+
abstract readonly key: {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly description: string;
|
|
19
|
+
};
|
|
20
|
+
abstract readonly output: Readonly<Record<string, RuleColumn>>;
|
|
21
|
+
abstract readonly batch: number;
|
|
22
|
+
abstract readonly concurrency: number;
|
|
23
|
+
abstract read(row: LoadedRow): {
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly input: Input;
|
|
26
|
+
} | null;
|
|
27
|
+
abstract identity(input: Input): Promise<string | null>;
|
|
28
|
+
abstract compute(inputs: readonly Input[], signal?: AbortSignal): Promise<readonly RuleResult<Readonly<Record<string, Output>>>[]>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// How a computed dataset turns each input into its stored output, such as a
|
|
2
|
+
// file's text or a listing's vector (a Strategy). Its identity names what made
|
|
3
|
+
// an output, the parser's format or the model, so an output made otherwise
|
|
4
|
+
// is made again, as pgai's vectorizer re-embeds rows after a model change.
|
|
5
|
+
export class Rule {
|
|
6
|
+
}
|