@eltjs/elt 0.1.2 → 0.2.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/README.md CHANGED
@@ -1058,7 +1058,7 @@ Two rules exist:
1058
1058
  ## When a derived dataset fails
1059
1059
 
1060
1060
  - **A rule failure** is the output of its input: the status `failed` and the reason. The load and the run continue, and the next refresh tries the input again. See [ADR 0004](./docs/adr/0004-a-rule-failure-stays-on-its-output.md).
1061
- - **A dataset failure**, for example SQL that SQLite refuses, rejects the run as itself, or it stops the watch.
1061
+ - **A dataset failure**, for example SQL that SQLite refuses, rejects the run, or it stops the watch, with a `DerivedDatasetError`. Its `dataset` names the dataset, and its `cause` is the error of the store. The passes that a stopped watch ended are `cancelled` in the sync history, and their records do not name the failure.
1062
1062
  - **A copy that failed** keeps its last committed rows. The derived data of those rows stays as it was.
1063
1063
 
1064
1064
  ## The same concepts in other systems
@@ -7,6 +7,11 @@ export type RefreshTrigger = {
7
7
  readonly kind: 'pass';
8
8
  readonly tried: ReadonlySet<string>;
9
9
  };
10
+ export declare class DerivedDatasetError extends Error {
11
+ name: string;
12
+ readonly dataset: string;
13
+ constructor(dataset: string, cause: unknown);
14
+ }
10
15
  export declare abstract class DerivedDataset<Target extends DestinationTarget> {
11
16
  abstract readonly name: string;
12
17
  validate(_connections: readonly Connection<Target>[]): void;
@@ -1,3 +1,17 @@
1
+ import { errorMessage } from '../state/sync-history.js';
2
+ // A dataset's own failure, such as SQL its store refuses, named for whoever
3
+ // reads it in a log: the cause alone, such as "no such column: x", does not say
4
+ // which dataset of the pipeline failed.
5
+ export class DerivedDatasetError extends Error {
6
+ name = 'DerivedDatasetError';
7
+ dataset;
8
+ constructor(dataset, cause) {
9
+ super(`Derived dataset ${dataset} failed: ${errorMessage(cause)}`, {
10
+ cause,
11
+ });
12
+ this.dataset = dataset;
13
+ }
14
+ }
1
15
  // Data a pipeline keeps in step with what its copies load, and can always
2
16
  // rebuild from it (Kleppmann's derived data; Estuary's derivations), such as a
3
17
  // view over loaded tables or the text of the files a copy stored. Each kind
@@ -1,10 +1,10 @@
1
1
  import type { Connection } from './connection.ts';
2
- import type { DerivedDataset, RefreshTrigger } from './derived-dataset.ts';
2
+ import { type DerivedDataset, type RefreshTrigger } from './derived-dataset.ts';
3
3
  import type { Target as DestinationTarget } from './target.ts';
4
4
  type Kind = RefreshTrigger['kind'];
5
5
  export declare class DerivedRefreshes<Target extends DestinationTarget> {
6
6
  #private;
7
- constructor(datasets: readonly DerivedDataset<Target>[], connections: readonly Connection<Target>[], signal: AbortSignal | undefined, stop: (error: unknown) => void);
7
+ constructor(datasets: readonly DerivedDataset<Target>[], connections: readonly Connection<Target>[], signal: AbortSignal | undefined, stop: () => void);
8
8
  get failure(): {
9
9
  readonly error: unknown;
10
10
  } | undefined;
@@ -1,4 +1,5 @@
1
1
  import { Latch } from '@zukhruf/async';
2
+ import { DerivedDatasetError, } from './derived-dataset.js';
2
3
  // No refresh runs.
3
4
  class Idle {
4
5
  }
@@ -84,12 +85,20 @@ export class DerivedRefreshes {
84
85
  for (const [index, dataset] of this.#datasets.entries()) {
85
86
  const tried = this.#tried.get(dataset) ?? new Set();
86
87
  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
- });
88
+ try {
89
+ await dataset.refresh({
90
+ connections: this.#connections,
91
+ earlier: this.#datasets.slice(0, index),
92
+ signal: this.#signal,
93
+ trigger: { kind, tried },
94
+ });
95
+ }
96
+ catch (error) {
97
+ // A stop is not the dataset's failure.
98
+ if (this.#signal?.aborted === true)
99
+ throw error;
100
+ throw new DerivedDatasetError(dataset.name, error);
101
+ }
93
102
  }
94
103
  // The passes it follows ended, and their memory with them.
95
104
  if (kind === 'pass')
@@ -109,7 +118,7 @@ export class DerivedRefreshes {
109
118
  catch (error) {
110
119
  if (this.#signal?.aborted !== true) {
111
120
  this.#health = new Failed(error);
112
- this.#stop(error);
121
+ this.#stop();
113
122
  }
114
123
  }
115
124
  finally {
@@ -52,7 +52,7 @@ export class DocumentText extends Rule {
52
52
  }
53
53
  catch (failure) {
54
54
  if (signal?.aborted ||
55
- (failure instanceof Error && failure.name === 'AbortError'))
55
+ (Error.isError(failure) && failure.name === 'AbortError'))
56
56
  return { gone: true };
57
57
  // A commit removed the copy after the refresh listed it; its rows
58
58
  // are gone too, so the file is not a failure.
@@ -1,6 +1,6 @@
1
- import { createHash } from 'node:crypto';
2
- function hash(name) {
3
- return createHash('sha256').update(name).digest('hex').slice(0, 8);
1
+ import { hash } from 'node:crypto';
2
+ function shortHash(name) {
3
+ return hash('sha256', name).slice(0, 8);
4
4
  }
5
5
  // Airbyte's rule: decomposed marks dropped, every run of whitespace and every
6
6
  // other character outside [A-Za-z0-9_] replaced by `_`, case kept, and a
@@ -40,12 +40,12 @@ export class Identifiers {
40
40
  const clean = sanitize(name);
41
41
  return clean === name
42
42
  ? this.#fit(name, name)
43
- : this.#fit(`${clean}_${hash(name)}`, name);
43
+ : this.#fit(`${clean}_${shortHash(name)}`, name);
44
44
  }
45
45
  // A sanitized name is ASCII, so cutting it by bytes keeps whole characters.
46
46
  #fit(name, original) {
47
47
  if (Buffer.byteLength(name) <= this.maxBytes)
48
48
  return name;
49
- return `${name.slice(0, this.maxBytes - 9)}_${hash(original)}`;
49
+ return `${name.slice(0, this.maxBytes - 9)}_${shortHash(original)}`;
50
50
  }
51
51
  }
@@ -128,7 +128,10 @@ export class Pipeline {
128
128
  const controller = new AbortController();
129
129
  const watching = AbortSignal.any([signal, controller.signal]);
130
130
  const stopped = [];
131
- const refreshes = new DerivedRefreshes(this.derived, this.connections, watching, (error) => controller.abort(error));
131
+ const refreshes = new DerivedRefreshes(this.derived, this.connections, watching,
132
+ // With no reason: the passes it stops were not refused by this failure,
133
+ // and no sync history records a dataset's failure (ADR 0004).
134
+ () => controller.abort());
132
135
  const passes = interleave(this.connections
133
136
  .filter(({ steps }) => steps.length > 0)
134
137
  .map((connection) => this.#watch(connection, watching, controller, stopped, () => refreshes.request('commit'))), Number.POSITIVE_INFINITY);
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { Copy, type CopyOutcome, type CopyResult } from './core/copy.ts';
5
5
  export { CopyConfiguration, type DedupPolicy, } from './core/copy-configuration.ts';
6
6
  export type { Deduplication } from './core/deduplication.ts';
7
7
  export { Destination, type DestinationSyncMode, type Load, } from './core/destination.ts';
8
- export { DerivedDataset, type RefreshTrigger } from './core/derived-dataset.ts';
8
+ export { DerivedDataset, DerivedDatasetError, type RefreshTrigger, } from './core/derived-dataset.ts';
9
9
  export { DocumentParser } from './core/document-parser.ts';
10
10
  export { DocumentText } from './core/document-text.ts';
11
11
  export { type LoadedRow, Rule, type RuleColumn, type RuleResult, } from './core/rule.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { Connection } from './core/connection.js';
3
3
  export { Copy } from './core/copy.js';
4
4
  export { CopyConfiguration, } from './core/copy-configuration.js';
5
5
  export { Destination, } from './core/destination.js';
6
- export { DerivedDataset } from './core/derived-dataset.js';
6
+ export { DerivedDataset, DerivedDatasetError, } from './core/derived-dataset.js';
7
7
  export { DocumentParser } from './core/document-parser.js';
8
8
  export { DocumentText } from './core/document-text.js';
9
9
  export { Rule, } from './core/rule.js';
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash, hash, randomUUID } from 'node:crypto';
2
2
  import { link, lstat, mkdir, open, readdir, rm } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileExtension } from '../core/file-extension.js';
@@ -26,7 +26,7 @@ export class LocalFiles extends FileStorage {
26
26
  }
27
27
  // The scope's folder, relative to the storage folder.
28
28
  scopeFolder(scope) {
29
- return join('.elt-files', createHash('sha256').update(scope).digest('hex'));
29
+ return join('.elt-files', hash('sha256', scope));
30
30
  }
31
31
  async inspect(path) {
32
32
  const info = await lstat(path).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/elt",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
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
5
  "license": "Apache-2.0",
6
6
  "repository": {