@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,37 @@
|
|
|
1
|
+
import type { Connection } from '../core/connection.ts';
|
|
2
|
+
import { type Copy, type CopyOutcome } from '../core/copy.ts';
|
|
3
|
+
import type { ExtractionCoverage, FailureType } from '../core/source.ts';
|
|
4
|
+
import type { Target as DestinationTarget } from '../core/target.ts';
|
|
5
|
+
import type { LoadFailure } from '../core/writer.ts';
|
|
6
|
+
export type SyncStatus = 'succeeded' | 'partial' | 'failed' | 'cancelled';
|
|
7
|
+
export type DeclaredCopy<Target extends DestinationTarget> = {
|
|
8
|
+
readonly copy: Copy<Target>;
|
|
9
|
+
readonly coverage: ExtractionCoverage;
|
|
10
|
+
};
|
|
11
|
+
export type StreamCheck = {
|
|
12
|
+
readonly stream: string;
|
|
13
|
+
readonly at: Date;
|
|
14
|
+
};
|
|
15
|
+
export type RecordedPass<Target extends DestinationTarget> = {
|
|
16
|
+
finish(outcomes: readonly CopyOutcome<Target>[], upstream: ReadonlyMap<string, Date>, at: Date): Promise<void>;
|
|
17
|
+
fail(error: unknown, failureType: FailureType, at: Date): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export declare abstract class SyncHistory<Target extends DestinationTarget> {
|
|
20
|
+
#private;
|
|
21
|
+
validate(_connection: Connection<Target>): void;
|
|
22
|
+
abstract install(connections: readonly Connection<Target>[], at: Date): Promise<void>;
|
|
23
|
+
abstract begin(connection: Connection<Target>, copies: readonly DeclaredCopy<Target>[], at: Date): Promise<RecordedPass<Target>>;
|
|
24
|
+
checked(connection: Connection<Target>, at: Date, current: (at: Date) => Promise<readonly StreamCheck[]>): Promise<void>;
|
|
25
|
+
protected abstract recordChecks(connection: Connection<Target>, checks: readonly StreamCheck[]): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function recordedFailure({ partition, error, failureType, }: LoadFailure): {
|
|
28
|
+
partition: Readonly<Record<string, import("../core/source.ts").KeyValue>> | null;
|
|
29
|
+
error: string;
|
|
30
|
+
failure_type: FailureType;
|
|
31
|
+
stacktrace: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function errorMessage(error: unknown): string;
|
|
34
|
+
export declare function copyStatus({ count, deleted, failures, cancelled, }: CopyOutcome<DestinationTarget>): SyncStatus;
|
|
35
|
+
export declare function passStatus(outcomes: readonly CopyOutcome<DestinationTarget>[]): SyncStatus;
|
|
36
|
+
export declare function passFailureType(outcomes: readonly CopyOutcome<DestinationTarget>[]): FailureType | null;
|
|
37
|
+
export declare function passError(outcomes: readonly CopyOutcome<DestinationTarget>[]): string | null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import { describeFailures } from '../core/copy.js';
|
|
3
|
+
// How often a history records the checks that found a connection's streams
|
|
4
|
+
// unchanged. A source checks about every second; a reader needs
|
|
5
|
+
// current_as_of only to about a minute, and every record is a write.
|
|
6
|
+
const checkRecordMs = 60_000;
|
|
7
|
+
// As Airbyte's platform keeps each connection's jobs and attempts apart from
|
|
8
|
+
// any connector, the orchestrator records every pass here. A pass is one
|
|
9
|
+
// connection's read over the copies it selected. A history reads no clock:
|
|
10
|
+
// the pipeline gives the time of each event it records, from its own clock.
|
|
11
|
+
export class SyncHistory {
|
|
12
|
+
// When each connection's checks were last recorded.
|
|
13
|
+
#checkedAt = new Map();
|
|
14
|
+
// Refuses, without I/O, a connection this history cannot record.
|
|
15
|
+
validate(_connection) { }
|
|
16
|
+
// At that moment a watch's source checked its streams and found them
|
|
17
|
+
// unchanged since their last copy loaded, so readers see them current as of
|
|
18
|
+
// then though no pass read them, as a Debezium heartbeat moves a connector's
|
|
19
|
+
// position with no change to carry. A connection's first check is recorded
|
|
20
|
+
// at once, and the next at most once a minute; current gives the streams
|
|
21
|
+
// to record only then, since asking a source can cost a process call.
|
|
22
|
+
async checked(connection, at, current) {
|
|
23
|
+
const last = this.#checkedAt.get(connection.name);
|
|
24
|
+
if (last !== undefined && at.getTime() - last < checkRecordMs)
|
|
25
|
+
return;
|
|
26
|
+
this.#checkedAt.set(connection.name, at.getTime());
|
|
27
|
+
await this.recordChecks(connection, await current(at));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// A failure as a history records it in a copy's failures. stacktrace is the
|
|
31
|
+
// error as Node renders it, with its stack and every cause it chains, such as
|
|
32
|
+
// the code of a refused connection behind "fetch failed", as Airbyte keeps a
|
|
33
|
+
// FailureReason's stacktrace beside its message.
|
|
34
|
+
export function recordedFailure({ partition, error, failureType, }) {
|
|
35
|
+
return {
|
|
36
|
+
partition,
|
|
37
|
+
error: errorMessage(error),
|
|
38
|
+
failure_type: failureType,
|
|
39
|
+
stacktrace: inspect(error),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// The message of a thrown value: an Error's message, or the value as a string,
|
|
43
|
+
// since JavaScript can throw a value that is not an Error. Error.isError also
|
|
44
|
+
// knows an Error from another realm, which instanceof Error does not.
|
|
45
|
+
export function errorMessage(error) {
|
|
46
|
+
return Error.isError(error) ? error.message : String(error);
|
|
47
|
+
}
|
|
48
|
+
export function copyStatus({ count, deleted, failures, cancelled, }) {
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return 'cancelled';
|
|
51
|
+
if (failures.length === 0)
|
|
52
|
+
return 'succeeded';
|
|
53
|
+
return count + deleted > 0 ? 'partial' : 'failed';
|
|
54
|
+
}
|
|
55
|
+
export function passStatus(outcomes) {
|
|
56
|
+
if (outcomes.some(({ cancelled }) => cancelled))
|
|
57
|
+
return 'cancelled';
|
|
58
|
+
if (outcomes.every(({ failures }) => failures.length === 0))
|
|
59
|
+
return 'succeeded';
|
|
60
|
+
return outcomes.some((outcome) => copyStatus(outcome) !== 'failed')
|
|
61
|
+
? 'partial'
|
|
62
|
+
: 'failed';
|
|
63
|
+
}
|
|
64
|
+
// Whose a pass's failures are to fix: config when any one is the user's, null
|
|
65
|
+
// when nothing failed or the run was stopped on request.
|
|
66
|
+
export function passFailureType(outcomes) {
|
|
67
|
+
const status = passStatus(outcomes);
|
|
68
|
+
if (status === 'succeeded' || status === 'cancelled')
|
|
69
|
+
return null;
|
|
70
|
+
return outcomes.some(({ failures }) => failures.some(({ failureType }) => failureType === 'config'))
|
|
71
|
+
? 'config'
|
|
72
|
+
: 'system';
|
|
73
|
+
}
|
|
74
|
+
// What did not load in a pass, or null when every copy loaded completely.
|
|
75
|
+
export function passError(outcomes) {
|
|
76
|
+
const incomplete = outcomes.filter(({ failures }) => failures.length > 0);
|
|
77
|
+
if (incomplete.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
return `The following copies did not load completely: ${incomplete.map(describeFailures).join('; ')}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FileContent } from '../core/file-content.ts';
|
|
2
|
+
import { FileStorage } from '../core/file-storage.ts';
|
|
3
|
+
export declare class LocalFiles extends FileStorage {
|
|
4
|
+
readonly directory: string;
|
|
5
|
+
readonly identity: string;
|
|
6
|
+
readonly reference = "Path of a copy of the source file bytes, relative to the folder the load stores files in. Named by content hash, keeping a short source extension, and kept only while a row references it. Not the source file name, a URL or extracted text.";
|
|
7
|
+
constructor({ directory }: {
|
|
8
|
+
directory: string;
|
|
9
|
+
});
|
|
10
|
+
private scopeFolder;
|
|
11
|
+
private inspect;
|
|
12
|
+
save(scope: string, content: FileContent): Promise<string>;
|
|
13
|
+
flush(scope: string): Promise<void>;
|
|
14
|
+
retain(scope: string, references: ReadonlySet<string>): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, lstat, mkdir, open, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileExtension } from '../core/file-extension.js';
|
|
5
|
+
import { FileStorage } from '../core/file-storage.js';
|
|
6
|
+
async function syncDirectory(path) {
|
|
7
|
+
await using directory = await open(path, 'r');
|
|
8
|
+
await directory.sync();
|
|
9
|
+
}
|
|
10
|
+
export class LocalFiles extends FileStorage {
|
|
11
|
+
directory;
|
|
12
|
+
identity;
|
|
13
|
+
reference = 'Path of a copy of the source file bytes, relative to the folder the load stores files in. Named by content hash, keeping a short source extension, and kept only while a row references it. Not the source file name, a URL or extracted text.';
|
|
14
|
+
constructor({ directory }) {
|
|
15
|
+
super();
|
|
16
|
+
if (typeof directory !== 'string' ||
|
|
17
|
+
directory.length === 0 ||
|
|
18
|
+
directory.includes('\0') ||
|
|
19
|
+
!directory.isWellFormed())
|
|
20
|
+
throw new TypeError('LocalFiles requires a directory');
|
|
21
|
+
this.directory = resolve(directory);
|
|
22
|
+
// References are relative to the folder, so the folder is no part of the
|
|
23
|
+
// identity: a store moved with its files keeps every reference.
|
|
24
|
+
this.identity = JSON.stringify({ type: 'local-files' });
|
|
25
|
+
Object.freeze(this);
|
|
26
|
+
}
|
|
27
|
+
// The scope's folder, relative to the storage folder.
|
|
28
|
+
scopeFolder(scope) {
|
|
29
|
+
return join('.elt-files', createHash('sha256').update(scope).digest('hex'));
|
|
30
|
+
}
|
|
31
|
+
async inspect(path) {
|
|
32
|
+
const info = await lstat(path).catch((error) => {
|
|
33
|
+
if (error.code !== 'ENOENT')
|
|
34
|
+
throw error;
|
|
35
|
+
return undefined;
|
|
36
|
+
});
|
|
37
|
+
if (info === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
if (!info.isDirectory())
|
|
40
|
+
throw new TypeError(`Managed attachment directories must be real directories: ${path}`);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
async save(scope, content) {
|
|
44
|
+
const folder = this.scopeFolder(scope);
|
|
45
|
+
const directory = join(this.directory, folder);
|
|
46
|
+
// Sync newly created directory entries as well as the file publication.
|
|
47
|
+
const created = await mkdir(directory, { recursive: true });
|
|
48
|
+
for (const path of [this.directory, dirname(directory), directory])
|
|
49
|
+
await this.inspect(path);
|
|
50
|
+
if (created !== undefined) {
|
|
51
|
+
let path = directory;
|
|
52
|
+
while (path !== dirname(created)) {
|
|
53
|
+
await syncDirectory(path);
|
|
54
|
+
path = dirname(path);
|
|
55
|
+
}
|
|
56
|
+
await syncDirectory(path);
|
|
57
|
+
}
|
|
58
|
+
const temporary = join(directory, `.pending-${randomUUID()}`);
|
|
59
|
+
try {
|
|
60
|
+
const hash = createHash('sha256');
|
|
61
|
+
{
|
|
62
|
+
await using file = await open(temporary, 'wx', 0o600);
|
|
63
|
+
for await (const chunk of content.chunks(4 * 1024 * 1024)) {
|
|
64
|
+
hash.update(chunk);
|
|
65
|
+
await file.writeFile(chunk);
|
|
66
|
+
}
|
|
67
|
+
await file.sync();
|
|
68
|
+
}
|
|
69
|
+
const name = hash.digest('hex') + fileExtension(content.path);
|
|
70
|
+
const path = join(directory, name);
|
|
71
|
+
await link(temporary, path).catch(async (error) => {
|
|
72
|
+
if (error.code !== 'EEXIST')
|
|
73
|
+
throw error;
|
|
74
|
+
if (!(await lstat(path)).isFile())
|
|
75
|
+
throw new TypeError(`Attachment reference is not a regular file: ${path}`);
|
|
76
|
+
});
|
|
77
|
+
return join(folder, name);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await rm(temporary, { force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async flush(scope) {
|
|
84
|
+
await syncDirectory(join(this.directory, this.scopeFolder(scope))).catch((error) => {
|
|
85
|
+
if (error.code !== 'ENOENT')
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async retain(scope, references) {
|
|
90
|
+
const folder = this.scopeFolder(scope);
|
|
91
|
+
const directory = join(this.directory, folder);
|
|
92
|
+
for (const path of [this.directory, dirname(directory), directory]) {
|
|
93
|
+
if (await this.inspect(path))
|
|
94
|
+
continue;
|
|
95
|
+
if (references.size > 0)
|
|
96
|
+
throw new Error(`Stored attachment directory is missing: ${directory}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// ponytail: scan this field's managed directory at commit; use an object
|
|
100
|
+
// index if attachment counts make reconciliation expensive.
|
|
101
|
+
const missing = new Set(references);
|
|
102
|
+
const obsolete = [];
|
|
103
|
+
// Only this scope's saves write here, so every file no row references is
|
|
104
|
+
// obsolete, a crashed save's .pending- file included.
|
|
105
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
106
|
+
const path = join(directory, entry.name);
|
|
107
|
+
if (!entry.isFile())
|
|
108
|
+
throw new TypeError(`Managed attachment is not a regular file: ${path}`);
|
|
109
|
+
const reference = join(folder, entry.name);
|
|
110
|
+
if (references.has(reference))
|
|
111
|
+
missing.delete(reference);
|
|
112
|
+
else
|
|
113
|
+
obsolete.push(path);
|
|
114
|
+
}
|
|
115
|
+
if (missing.size > 0)
|
|
116
|
+
throw new Error(`Stored attachment is missing or outside its scope: ${[...missing][0]}`);
|
|
117
|
+
for (const path of obsolete)
|
|
118
|
+
await rm(path);
|
|
119
|
+
await syncDirectory(directory);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eltjs/elt",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A pipeline that copies the data of apps and services into stores that SQL can read: sources, streams, checkpoints, watches and derived datasets.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/ezzabuzaid/elt",
|
|
9
|
+
"directory": "packages/elt"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ezzabuzaid/elt/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ezzabuzaid/elt/tree/main/packages/elt#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"elt",
|
|
17
|
+
"etl",
|
|
18
|
+
"pipeline",
|
|
19
|
+
"data-pipeline",
|
|
20
|
+
"change-data-capture",
|
|
21
|
+
"sync"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
"./package.json": "./package.json",
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"!dist/spec/**",
|
|
34
|
+
"!**/*.tsbuildinfo",
|
|
35
|
+
"!**/*.test.*"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=24"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@zukhruf/async": "^0.8.10",
|
|
45
|
+
"@zukhruf/snapshot-differential": "^0.8.10"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@zukhruf/testing": "^0.8.10"
|
|
49
|
+
}
|
|
50
|
+
}
|