@eltjs/elt 0.3.2 → 0.3.4

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
@@ -158,7 +158,7 @@ The shape of a stream is its fields and their types, and its declarations. The f
158
158
  - `CheckpointStore.reset(id)` keeps the stored rows and loads again from no checkpoint. This is the refresh of Airbyte that keeps records.
159
159
  - `pipeline.clear()` drops the rows. This is the Clear of Airbyte.
160
160
  - **A cursor field that the stream does not describe now** fails the copy before it reads. The copy must select a different cursor. That changes its binding, so the host also resets it.
161
- - **Stored nullability.** A stored table is `NOT NULL` only on `loaded_at` and on the explicit primary key columns, because a field that is added later has no value on earlier rows. The Postgres destination of Airbyte also adds columns as nullable, because it does not "know what constitutes a reasonable default value for preexisting records" ([source](https://github.com/airbytehq/airbyte/blob/f847ad015360face1837f5a77c64325f104157f0/airbyte-integrations/connectors/destination-postgres/src/main/kotlin/io/airbyte/integrations/destination/postgres/sql/PostgresDirectLoadSqlGenerator.kt#L608-L615)). The destination still checks each record that it writes for its required fields, and a deduplicating load refuses a NULL key or cursor.
161
+ - **Stored nullability.** A stored table is `NOT NULL` only on `loaded_at`, on `committed_in` and on the explicit primary key columns, because a field that is added later has no value on earlier rows. The Postgres destination of Airbyte also adds columns as nullable, because it does not "know what constitutes a reasonable default value for preexisting records" ([source](https://github.com/airbytehq/airbyte/blob/f847ad015360face1837f5a77c64325f104157f0/airbyte-integrations/connectors/destination-postgres/src/main/kotlin/io/airbyte/integrations/destination/postgres/sql/PostgresDirectLoadSqlGenerator.kt#L608-L615)). The destination still checks each record that it writes for its required fields, and a deduplicating load refuses a NULL key or cursor.
162
162
 
163
163
  ### Target ownership
164
164
 
@@ -248,7 +248,7 @@ A SQL destination gives each column, table and reader view a name that it can ho
248
248
  6. A name that starts with a digit gets a `_` in front (`1st` becomes `_1st`).
249
249
 
250
250
  - Columns get their names in the order of the fields. When an earlier column has the name, the field gets `_1`, then `_2` and so on, after it (`a b` becomes `a_b`, and a later `a_b` becomes `a_b_1`).
251
- - The names that the destination writes come first. `loaded_at` is one in the two destinations. Postgres also has the system column names `xmin`, `xmax`, `cmin`, `cmax`, `ctid` and `tableoid`. Thus a field `loaded_at` becomes `loaded_at_1`.
251
+ - The names that the destination writes come first. In the two destinations they are `loaded_at`, `committed_in`, `_elt_row_hash` and `replaced_in`. SQLite also has `_rowid_`, and Postgres has the system column names `xmin`, `xmax`, `cmin`, `cmax`, `ctid` and `tableoid`. Thus a field `loaded_at` becomes `loaded_at_1`.
252
252
  - Postgres compares names with their case. SQLite compares names without ASCII case, so `Foo` and `foo` become `Foo` and `foo_1`.
253
253
  - A table or view does not see the names of other tables. When the rule changes its name, the name gets `_` and the first 8 hex digits of the SHA-256 of the requested name (`dbo.a b` becomes `dbo_a_b_<hash>`). Thus two requested names never share a table.
254
254
  - Postgres keeps only 63 bytes of a name. A longer name keeps its first 54 bytes, then `_` and the first 8 hex digits of the SHA-256 of the field or the requested name. SQLite has no length limit.
@@ -810,6 +810,7 @@ try {
810
810
  ```
811
811
 
812
812
  - `PipelineError` is an `AggregateError`. `errors` holds each failure. `results` holds the committed `count` and `deleted` of each copy beside its `failures`, in the order of the connections, then of the copies. Each failure has its `partition`, `error` and [`failureType`](#failure-types).
813
+ - The `message` of `PipelineError`, and the error that `passError` gives the sync history, names each distinct failure message once, with each copy and partition that failed with it. Thus a store that refuses five streams gives its remedy once: `The following copies did not load completely: calls, contacts: Turn on … Full Disk Access.; notes: The store is corrupt.`
813
814
  - A failure after rows committed, such as a checkpoint that the store could not save or a cleanup error after the publication, shows in the same way, with the committed counts. It does not mean a rollback.
814
815
  - A count of zero also covers an empty input that committed.
815
816
  - A `PipelineError` holds only failures that the sync history recorded. A pass that could not run, such as a pass whose sync history the pipeline could not write, is recorded nowhere. Thus `run()` rejects with that error itself, after each other pass ends.
@@ -973,7 +974,7 @@ Each source tells its answer in its README. Most sources answer `null`. These an
973
974
  Four clocks have different meanings:
974
975
 
975
976
  - The modification fields of a source record tell of changes in the upstream.
976
- - `loaded_at` tells of the load that last wrote a row.
977
+ - `loaded_at` tells of the load that last wrote a row. It is the one column of its own that a destination shows to readers: a reader view shows the columns of the stream and `loaded_at`, now and as of a position. The other columns of the destination are internal ([SQLite](../destinations/sqlite/README.md#columns), [Postgres](../destinations/postgresql/README.md#reader-views)).
977
978
  - `last_successful_sync_at` tells when a recorded pass ended.
978
979
  - `current_as_of` tells when a stream last agreed with its upstream: the start of its last successful pass, or a later watch check that found it unchanged. It is never later than when its source tells that the upstream itself was last current. Use it to judge freshness.
979
980
 
@@ -14,7 +14,7 @@ export type CopyOutcome<Target extends DestinationTarget> = CopyResult<Target> &
14
14
  readonly failures: readonly LoadFailure[];
15
15
  readonly cancelled: boolean;
16
16
  };
17
- export declare function describeFailures<Target extends DestinationTarget>({ copy, failures, }: CopyOutcome<Target>): string;
17
+ export declare function describeFailures<Target extends DestinationTarget>(outcomes: readonly CopyOutcome<Target>[]): string;
18
18
  export declare class Copy<Target extends DestinationTarget> {
19
19
  readonly configuration: CopyConfiguration;
20
20
  readonly to: Target;
package/dist/core/copy.js CHANGED
@@ -2,10 +2,18 @@ import { errorMessage } from '../state/sync-history.js';
2
2
  import { CopyConfiguration } from './copy-configuration.js';
3
3
  import { FileTransfer } from './file-transfer.js';
4
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('; ');
5
+ // What did not load, naming each distinct failure message once with every
6
+ // copy and partition it failed, in order of first appearance, as a log
7
+ // aggregator folds repeated lines: a store that refuses five streams gives
8
+ // its remedy once.
9
+ export function describeFailures(outcomes) {
10
+ const failed = new Map();
11
+ for (const { copy, failures } of outcomes)
12
+ for (const { partition, error } of failures)
13
+ failed
14
+ .getOrInsertComputed(errorMessage(error), () => [])
15
+ .push(`${copy.id ?? copy.from.name}${partition === null ? '' : ` ${JSON.stringify(partition)}`}`);
16
+ return `The following copies did not load completely: ${Array.from(failed, ([message, copies]) => `${copies.join(', ')}: ${message}`).join('; ')}`;
9
17
  }
10
18
  // One configured transfer from a source stream to a destination declaration.
11
19
  export class Copy {
@@ -33,9 +33,8 @@ export class PipelineError extends AggregateError {
33
33
  name = 'PipelineError';
34
34
  results;
35
35
  constructor(results) {
36
- const incomplete = results.filter(({ failures }) => failures.length > 0);
37
- const errors = incomplete.flatMap(({ failures }) => failures.map(({ error }) => error));
38
- super(errors, `The following copies did not load completely: ${incomplete.map(describeFailures).join('; ')}`, { cause: errors[0] });
36
+ const errors = results.flatMap(({ failures }) => failures.map(({ error }) => error));
37
+ super(errors, describeFailures(results), { cause: errors[0] });
39
38
  this.results = Object.freeze([...results]);
40
39
  }
41
40
  }
@@ -73,8 +73,7 @@ export function passFailureType(outcomes) {
73
73
  }
74
74
  // What did not load in a pass, or null when every copy loaded completely.
75
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('; ')}`;
76
+ return outcomes.some(({ failures }) => failures.length > 0)
77
+ ? describeFailures(outcomes)
78
+ : null;
80
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eltjs/elt",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {