@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.
Files changed (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1153 -2
  3. package/dist/core/catalog.d.ts +7 -0
  4. package/dist/core/catalog.js +23 -0
  5. package/dist/core/change-signal.d.ts +9 -0
  6. package/dist/core/change-signal.js +4 -0
  7. package/dist/core/clock.d.ts +6 -0
  8. package/dist/core/clock.js +1 -0
  9. package/dist/core/connection.d.ts +20 -0
  10. package/dist/core/connection.js +31 -0
  11. package/dist/core/copy-configuration.d.ts +24 -0
  12. package/dist/core/copy-configuration.js +120 -0
  13. package/dist/core/copy.d.ts +27 -0
  14. package/dist/core/copy.js +56 -0
  15. package/dist/core/deduplication.d.ts +14 -0
  16. package/dist/core/deduplication.js +96 -0
  17. package/dist/core/derived-dataset.d.ts +19 -0
  18. package/dist/core/derived-dataset.js +10 -0
  19. package/dist/core/derived-refreshes.d.ts +15 -0
  20. package/dist/core/derived-refreshes.js +120 -0
  21. package/dist/core/destination.d.ts +22 -0
  22. package/dist/core/destination.js +17 -0
  23. package/dist/core/document-parser.d.ts +6 -0
  24. package/dist/core/document-parser.js +15 -0
  25. package/dist/core/document-text.d.ts +28 -0
  26. package/dist/core/document-text.js +72 -0
  27. package/dist/core/file-content.d.ts +5 -0
  28. package/dist/core/file-content.js +24 -0
  29. package/dist/core/file-extension.d.ts +1 -0
  30. package/dist/core/file-extension.js +9 -0
  31. package/dist/core/file-read.d.ts +20 -0
  32. package/dist/core/file-read.js +79 -0
  33. package/dist/core/file-storage.d.ts +8 -0
  34. package/dist/core/file-storage.js +4 -0
  35. package/dist/core/file-transfer.d.ts +12 -0
  36. package/dist/core/file-transfer.js +65 -0
  37. package/dist/core/formats/base64-format.d.ts +6 -0
  38. package/dist/core/formats/base64-format.js +13 -0
  39. package/dist/core/formats/calendar-date-format.d.ts +6 -0
  40. package/dist/core/formats/calendar-date-format.js +13 -0
  41. package/dist/core/formats/decimal-format.d.ts +9 -0
  42. package/dist/core/formats/decimal-format.js +41 -0
  43. package/dist/core/formats/declared-format.d.ts +12 -0
  44. package/dist/core/formats/declared-format.js +48 -0
  45. package/dist/core/formats/field-format.d.ts +6 -0
  46. package/dist/core/formats/field-format.js +10 -0
  47. package/dist/core/formats/int64-format.d.ts +6 -0
  48. package/dist/core/formats/int64-format.js +16 -0
  49. package/dist/core/formats/local-time-format.d.ts +6 -0
  50. package/dist/core/formats/local-time-format.js +10 -0
  51. package/dist/core/formats/local-timestamp-format.d.ts +6 -0
  52. package/dist/core/formats/local-timestamp-format.js +10 -0
  53. package/dist/core/formats/temporal-format.d.ts +7 -0
  54. package/dist/core/formats/temporal-format.js +23 -0
  55. package/dist/core/formats/timestamp-format.d.ts +7 -0
  56. package/dist/core/formats/timestamp-format.js +14 -0
  57. package/dist/core/identifiers.d.ts +10 -0
  58. package/dist/core/identifiers.js +51 -0
  59. package/dist/core/interleave.d.ts +1 -0
  60. package/dist/core/interleave.js +38 -0
  61. package/dist/core/partition.d.ts +16 -0
  62. package/dist/core/partition.js +60 -0
  63. package/dist/core/pass-channels.d.ts +46 -0
  64. package/dist/core/pass-channels.js +65 -0
  65. package/dist/core/pipeline.d.ts +37 -0
  66. package/dist/core/pipeline.js +546 -0
  67. package/dist/core/reader-relation.d.ts +6 -0
  68. package/dist/core/reader-relation.js +11 -0
  69. package/dist/core/record-validation.d.ts +58 -0
  70. package/dist/core/record-validation.js +128 -0
  71. package/dist/core/reload-mode.d.ts +9 -0
  72. package/dist/core/reload-mode.js +17 -0
  73. package/dist/core/replication.d.ts +8 -0
  74. package/dist/core/replication.js +478 -0
  75. package/dist/core/rule.d.ts +29 -0
  76. package/dist/core/rule.js +6 -0
  77. package/dist/core/signal-watch.d.ts +11 -0
  78. package/dist/core/signal-watch.js +110 -0
  79. package/dist/core/snapshot.d.ts +17 -0
  80. package/dist/core/snapshot.js +111 -0
  81. package/dist/core/source.d.ts +79 -0
  82. package/dist/core/source.js +227 -0
  83. package/dist/core/stream-change.d.ts +5 -0
  84. package/dist/core/stream-change.js +31 -0
  85. package/dist/core/stream.d.ts +26 -0
  86. package/dist/core/stream.js +86 -0
  87. package/dist/core/target-description.d.ts +15 -0
  88. package/dist/core/target-description.js +73 -0
  89. package/dist/core/target.d.ts +5 -0
  90. package/dist/core/target.js +14 -0
  91. package/dist/core/upkeep.d.ts +4 -0
  92. package/dist/core/upkeep.js +1 -0
  93. package/dist/core/writer.d.ts +41 -0
  94. package/dist/core/writer.js +17 -0
  95. package/dist/index.d.ts +40 -0
  96. package/dist/index.js +36 -0
  97. package/dist/state/checkpoint-store.d.ts +29 -0
  98. package/dist/state/checkpoint-store.js +81 -0
  99. package/dist/state/sync-history-relations.d.ts +72 -0
  100. package/dist/state/sync-history-relations.js +74 -0
  101. package/dist/state/sync-history.d.ts +37 -0
  102. package/dist/state/sync-history.js +80 -0
  103. package/dist/storage/local-files.d.ts +15 -0
  104. package/dist/storage/local-files.js +121 -0
  105. package/package.json +48 -4
package/README.md CHANGED
@@ -1,3 +1,1154 @@
1
- # Temporary Holding Version
1
+ # @eltjs/elt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Copies the data of apps and services into stores that SQL can read. A source reads an upstream, for example the Mail store of a Mac. A destination keeps each stream as a table, for example in one SQLite file. The pipeline starts the copies, and it keeps derived datasets in step with what they load. A derived dataset is data that the pipeline can always make again from the loaded data, for example the text of each stored file, or a view that counts rows. Kleppmann calls it derived data, and Estuary calls it a derivation.
4
+
5
+ This README tells how a pipeline copies, resumes, watches, fails and records its passes, and how it keeps derived datasets. The SQLite destination is in [elt-sqlite](../destinations/sqlite/README.md), and the Postgres destination is in [elt-postgresql](../destinations/postgresql/README.md).
6
+
7
+ The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
8
+
9
+ ## Copy a stream
10
+
11
+ This example copies two streams of Apple Notes into one SQLite file. The pipeline types come from `elt`. The SQLite destination, its checkpoint store and its sync history come from `elt-sqlite`. Each Apple source has its own package, `@eltjs/source-apple-<name>` in `packages/sources/apple`. The later examples use `notes`, `sqlite`, `checkpoints` and `history` from this example.
12
+
13
+ ```ts
14
+ import { homedir } from 'node:os';
15
+
16
+ import { Connection, Copy, Pipeline } from '@eltjs/elt';
17
+ import {
18
+ SQLiteCheckpointStore,
19
+ SQLiteDestination,
20
+ SQLiteSyncHistory,
21
+ } from '@eltjs/elt-sqlite';
22
+ import { noteStorePath } from '@eltjs/sdk-apple-notes';
23
+ import { AppleNotesSource } from '@eltjs/source-apple-notes/apple-notes-source';
24
+
25
+ const notes = new AppleNotesSource({ path: noteStorePath(homedir()) });
26
+ const sqlite = new SQLiteDestination({ path: './notes.sqlite' });
27
+ const checkpoints = new SQLiteCheckpointStore({ path: './checkpoints.sqlite' });
28
+ const history = new SQLiteSyncHistory();
29
+
30
+ const connection = new Connection({
31
+ name: 'apple-notes',
32
+ source: notes,
33
+ destination: sqlite,
34
+ checkpoints,
35
+ steps: [
36
+ new Copy(
37
+ notes.accounts,
38
+ sqlite.table('accounts', (columns) => [
39
+ columns.text('id').primaryKey(),
40
+ columns.text('name').notNull(),
41
+ ]),
42
+ ),
43
+ new Copy(notes.notes, sqlite.table('notes'), {
44
+ id: 'notes-to-sqlite',
45
+ syncMode: 'incremental',
46
+ destinationSyncMode: 'append_dedup',
47
+ }),
48
+ ],
49
+ });
50
+ const pipeline = new Pipeline({
51
+ history,
52
+ connections: [connection],
53
+ clock: globalThis,
54
+ });
55
+
56
+ const results = await pipeline.run(); // [{ copy, count, deleted }, ...] in declaration order
57
+ ```
58
+
59
+ - A `Connection` is the connection of Airbyte: the copies of one source into one destination, with the checkpoints that resume them. Readers of the sync history see its `name`.
60
+ - A `Pipeline` is the orchestrator of one or more connections. See [Run and failures](#run-and-failures).
61
+ - The pipeline waits and reads the time on its `clock`. An app gives `globalThis`. See [Run a pipeline on a clock](#run-a-pipeline-on-a-clock).
62
+ - The copy of `accounts` declares its columns. The copy of `notes` infers its columns from the stream, and it deduplicates on the `id` key of the stream.
63
+ - Apple Notes gives its streams as properties: `accounts`, `folders`, `notes`, `inlineAttachments` and `attachments`. `discover()` gives the same frozen streams to code that lists streams.
64
+ - A declaration does not read Notes, and `discover()` does not read Notes. Neither opens a store.
65
+
66
+ ## Sync modes
67
+
68
+ `syncMode` selects how the source extracts. `destinationSyncMode` selects how the destination loads. The SQLite and Postgres destinations have these five pairs:
69
+
70
+ | Extraction | Loading | Result |
71
+ | -------------- | ----------------- | ----------------------------------------------------------------------------- |
72
+ | `full_refresh` | `append` | Keeps each observation, also an ID that repeats in one pass or across passes. |
73
+ | `full_refresh` | `overwrite` | Replaces the target with this extraction. Repeated IDs stay. |
74
+ | `full_refresh` | `overwrite_dedup` | Replaces the target with one row for each key, by the dedup policy. |
75
+ | `incremental` | `append` | Resumes from the last saved checkpoint, and keeps each emitted observation. |
76
+ | `incremental` | `append_dedup` | Resumes from the last saved checkpoint, and keeps one row for each key. |
77
+
78
+ - A `Copy` with no third argument is `full_refresh` with `overwrite`.
79
+ - An options object must give the two modes.
80
+ - The copy refuses `incremental` with `overwrite` or `overwrite_dedup`. It refuses `full_refresh` with `append_dedup`. Use `overwrite_dedup` for that result.
81
+ - The modes are immutable metadata of the stream and of the destination:
82
+
83
+ ```ts
84
+ notes.notes.supportedSyncModes; // ['full_refresh', 'incremental']
85
+ sqlite.supportedDestinationSyncModes;
86
+ // ['overwrite', 'append', 'append_dedup', 'overwrite_dedup']
87
+ ```
88
+
89
+ The pairs are the [sync modes of Airbyte](https://docs.airbyte.com/platform/using-airbyte/core-concepts/sync-modes). The platform of Airbyte [maps Full Refresh Overwrite + Deduped to `full_refresh` + `overwrite_dedup`](https://github.com/airbytehq/airbyte-platform/blob/main/airbyte-server/src/main/kotlin/io/airbyte/server/apis/publicapi/helpers/AirbyteCatalogHelper.kt). Its [serializer](https://github.com/airbytehq/airbyte-platform/blob/main/airbyte-commons-protocol/src/main/kotlin/io/airbyte/commons/protocol/DefaultProtocolSerializer.kt) maps `overwrite_dedup` to `append_dedup` with generation metadata for a destination that can refresh, and to `overwrite` for other destinations. The [wire protocol enum](https://github.com/airbytehq/airbyte-protocol/blob/main/protocol-models/src/main/resources/airbyte_protocol/v0/airbyte_protocol.yaml) of Airbyte does not have `overwrite_dedup`. elt uses the words of the platform and a protocol in the process. It is not compatible with the wire protocol of Airbyte. The default policy and the tie rule below are choices of elt.
90
+
91
+ ### Dedup policy
92
+
93
+ `dedupPolicy` selects how `append_dedup` and `overwrite_dedup` resolve a conflict on the key:
94
+
95
+ | `dedupPolicy` | Guard on the upsert | Use it when |
96
+ | -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------- |
97
+ | `cursor_newer` | `WHERE excluded.<cursor> > target.<cursor>` | The cursor moves forward apart from the key, so a lower cursor is an old replay. |
98
+ | `replace` | none | The upstream restates facts that it gave before, so the newest extraction is correct. |
99
+
100
+ - A deduplicating copy with no `dedupPolicy` uses `cursor_newer`. For a stream with `sourceDefinedCursor`, it uses `replace`.
101
+ - The two destinations apply the policy. SQLite applies it as the guard of the upsert. Postgres applies it when it merges each staged commit into its table.
102
+ - The copy refuses `cursor_newer` when the cursor is a field of the key. A conflict on that key has an equal cursor, so the guard never applies, and a restated record loads as a no-op that counts but changes no row. The error names the field and tells you to use `replace`.
103
+
104
+ ```ts
105
+ // A daily report keyed [account, date] whose upstream restates recent days.
106
+ new Copy(source.dailyTotals, destination.table('daily_totals'), {
107
+ id: 'daily-totals',
108
+ syncMode: 'incremental',
109
+ destinationSyncMode: 'append_dedup',
110
+ dedupPolicy: 'replace',
111
+ cursorField: 'date',
112
+ });
113
+ ```
114
+
115
+ ### Deletions
116
+
117
+ A stream that declares `emitsDeletes` can send `DELETE` messages in an incremental read. Each `DELETE` holds the fields of the `primaryKey` of the stream, and no other field. Such a stream always declares its key. Its incremental copies must use `append_dedup`, which deduplicates on that key, so the destination can find the row.
118
+
119
+ | Destination | What a `DELETE` does |
120
+ | ----------------------------------- | ---------------------------------------------------------- |
121
+ | SQLite and Postgres, `append_dedup` | Deletes the row with that key in the commit of the stream. |
122
+
123
+ - Records and deletions apply in the order that the source emits them.
124
+ - A `DELETE` of a key that is not there does nothing. Thus a pass can run again safely.
125
+ - The copy refuses a deletion when the stream does not declare `emitsDeletes`, when the key is not correct, or when the load does not deduplicate.
126
+ - The results count the accepted deletions as `deleted`, apart from `count`.
127
+
128
+ ### Resets
129
+
130
+ A stream that emits deletions can also send `{ type: 'RESET', stream }`. It sends it when the upstream cannot tell what changed since the checkpoint, for example when its change history expired. The stream then starts again in a hidden target:
131
+
132
+ - What the stream emits after the reset commits into the hidden target. Its checkpoints also go there.
133
+ - Readers see the earlier rows until the stream ends with no failure. Then the hidden target takes the place of the target.
134
+ - A reload that fails stays hidden. The engine saves with each checkpoint whether a reload is open, so the next run resumes the reload from its last checkpoint.
135
+ - The destination drops a hidden target that no checkpoint continues. A new reset starts the reload again.
136
+ - For a partitioned stream, a reset applies only to the partition that the source reads. The next commit drops the rows of that partition, and keeps only what the source emits after the reset.
137
+ - A reset drops the operations that the stream staged before it, in its scope.
138
+ - `Source.read` gives each reset the scope of its partition.
139
+ - A full refresh ignores a reset, because a full refresh starts again in all cases. Thus an appending copy keeps its history.
140
+
141
+ A stream whose fields changed, or whose stored table does not agree with the stream, reloads in this way only when its copy overwrites. Other copies [evolve](#shape-changes) the target. See [ADR 0007](./docs/adr/0007-a-stream-that-starts-over-reloads-into-a-hidden-target.md).
142
+
143
+ ### Shape changes
144
+
145
+ The shape of a stream is its fields and their types, and its declarations. The fields and types are its schema without the descriptions. The declarations are its name, primary key, partition key, sync modes, file transfer, source-defined cursor, deletions and expiry. A shape change keeps what the copies loaded. This is the rule of Airbyte: its [schema change management](https://github.com/airbytehq/airbyte/blob/f847ad015360face1837f5a77c64325f104157f0/docs/platform/using-airbyte/schema-change-management.md#L26-L31) never reads a source again by itself. See [ADR 0006](./docs/adr/0006-a-stream-whose-fields-change-keeps-its-rows-and-its-checkpoint.md).
146
+
147
+ - **Changed fields** (added, removed, a new type, nullable or required) keep the checkpoint and the rows of the copy. Before the copy reads, the destination evolves the stored target to the new shape, in place, as the direct-load destinations of Airbyte [alter a table](https://github.com/airbytehq/airbyte/blob/f847ad015360face1837f5a77c64325f104157f0/airbyte-cdk/bulk/core/load/src/main/kotlin/io/airbyte/cdk/load/component/TableSchemaEvolutionClient.kt#L59-L111):
148
+ - An added column is NULL on the rows that loaded before it.
149
+ - The destination drops a removed column.
150
+ - The destination converts a column with a new type.
151
+ - A stored value that the new type cannot hold fails the copy. The error names the column. The table and the checkpoint stay as they were. Clear the copy to load it again, or change the stream so that the value fits.
152
+ - Rows that the upstream does not hold now stay. Examples are notifications that the upstream forgot, or activity that expired.
153
+ - **An overwrite** (`overwrite` or `overwrite_dedup`) replaces all rows in all cases. Thus its stored target reloads into a [hidden target](#resets) of the new shape. The hidden target takes the place of the target when the stream ends. The truncate syncs of Airbyte also [load a new table](https://github.com/airbytehq/airbyte/blob/f847ad015360face1837f5a77c64325f104157f0/airbyte-cdk/bulk/core/load/src/main/kotlin/io/airbyte/cdk/load/table/directload/DirectLoadTableStreamLoader.kt#L222-L246), and they do not alter a table that holds the rows of an earlier sync.
154
+ - **A new field gets values from the source**, because the copy resumes from its checkpoint:
155
+ - A snapshot diff emits again each record whose fields changed, because its fingerprint changed.
156
+ - A cursor source whose cursor does not see a new column must record in its state the fields that it read. When a field is added, it reads all rows again, as the [SQL Server source](../sources/microsoft/sql-server/README.md) does. Airbyte leaves this to a [backfill](https://github.com/airbytehq/airbyte-platform/blob/090c5e993543735758a60184546fe87354af3d32/airbyte-commons-worker/src/main/kotlin/io/airbyte/workers/helper/BackfillHelper.kt#L100) setting that clears the state.
157
+ - **Changed declarations** stop the copy with `StreamChangeError`, which names them. Airbyte also pauses a connection when its primary key or cursor was [removed](https://github.com/airbytehq/airbyte-platform/blob/090c5e993543735758a60184546fe87354af3d32/airbyte-commons-protocol/src/main/kotlin/io/airbyte/commons/protocol/CatalogDiffHelpers.kt#L320-L350). The host selects one action:
158
+ - `CheckpointStore.reset(id)` keeps the stored rows and loads again from no checkpoint. This is the refresh of Airbyte that keeps records.
159
+ - `pipeline.clear()` drops the rows. This is the Clear of Airbyte.
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.
162
+
163
+ ### Target ownership
164
+
165
+ A target has one writer. Airbyte has the same rule: one stream owns one table, and ["more than one Airbyte connection to sync to the same destination stream… isn't permitted"](https://github.com/airbytehq/airbyte/blob/65c1b23b53ca4929ff18adbc3a3ef92666a3fffe/docs/platform/using-airbyte/configuring-schema.md#L42-L54). An overwrite empties the full target, and a snapshot copy deletes keys that it saw before. Thus the rows of a second writer would be lost. See [ADR 0008](./docs/adr/0008-a-target-has-one-writer.md).
166
+
167
+ - The writer is the `id` of the copy. For a copy with no `id`, the writer is the source identity and the stream name.
168
+ - Each destination records the writer of each target in the reserved table `_elt_writers`. It stores the writer with the target, in the commit of its first load.
169
+ - Before a copy extracts or changes anything, the target refuses each other writer with `TargetOwnedError`. The error names the two writers.
170
+ - The writer that owns the target can change its own mode or key.
171
+ - `location(target)` of a destination is the address of the target in the destination: `"schema"."table"` for Postgres, and the table name for SQLite. It has no path, host or credential. Thus a database that moves to a different folder, or that a host reaches through a different host name, keeps its checkpoints and its stored files. See [ADR 0009](./docs/adr/0009-a-target-is-named-by-its-address-inside-the-destination.md).
172
+
173
+ To reset or reassign a target, clear it with the copy that owns it, as the Clear of Airbyte does:
174
+
175
+ - `pipeline.clear()` clears every copy. `pipeline.clear([copy])` sends each copy to its connection. `connection.clear()` clears the copies of one connection.
176
+ - A clear empties each target, releases its writer and removes the checkpoint of the copy. The next run loads again from the start.
177
+ - A clear empties a table. It does not drop it. Thus views over the table, such as the [warehouse](../../infra/README.md) views, continue to work.
178
+ - A clear refuses a target that a different writer owns.
179
+ - A target that a person dropped by hand also releases its writer. Its copy loads it again from no checkpoint, because a resume would load only what changed since the checkpoint.
180
+
181
+ Several properties or accounts share tables through one [partitioned stream](#partitioned-streams), which is one writer.
182
+
183
+ ## Keys, cursors and schemas
184
+
185
+ Three different things control identity:
186
+
187
+ - `Stream.primaryKey` is the identity of the source, as `sourceDefinedCursor` is its cursor (Airbyte calls the key `source_defined_primary_key`). A deduplicating copy of a stream that declares a key uses that key. The key alone does not make a SQL unique constraint, and it does not select deduplication.
188
+ - The `Copy` option `primaryKey: ['id']` or `['tenantId', 'id']` selects the identity only for a stream that declares no key. The constructor of `Copy` refuses `primaryKey` for a stream with a key: `Stream <name> defines its own primary key; omit primaryKey`.
189
+ - `.primaryKey()` on a SQLite column is a physical constraint. An append that conflicts with it fails and rolls back that copy. It never becomes an update.
190
+
191
+ Rules for keys and cursors:
192
+
193
+ - The two deduplication modes need a key: the key of the stream, or the `primaryKey` of the copy for a stream with no key. Otherwise the copy fails with `Stream <name> declares no primary key; select primaryKey`.
194
+ - A load that does not deduplicate refuses `primaryKey` and `dedupPolicy`.
195
+ - `cursor_newer` needs `cursorField`. `replace` works with no cursor.
196
+ - A stream with `sourceDefinedCursor` has no cursor field. Its copies do not give `cursorField`. Its deduplicating loads use `replace`, and they refuse `cursor_newer`.
197
+ - An incremental copy of any other stream needs `cursorField`. A full refresh with `append` or `overwrite` refuses `cursorField`.
198
+ - The fields must be scalar properties at the top level, with one JSON Schema type that is not null.
199
+ - A key can be text, a finite number, a safe integer or a boolean. A cursor can be text or a number.
200
+ - A key or a cursor with a [string format](#string-formats) must have its canonical form. A `decimal` field and a base64 field can be keys. They cannot be cursors, because their text does not sort as their values do.
201
+ - A missing value or a null value fails. Composite keys are possible. Nested field paths and nullable keys or cursors are not possible.
202
+ - An explicit SQL projection must have all the key fields and the cursor, with types that agree.
203
+
204
+ The greatest cursor wins:
205
+
206
+ - After validation, the destination ignores a record with a lower cursor.
207
+ - With equal cursors, the record that the destination stored first stays, also across runs. Thus a replay gives the same rows. A source that changes the content of a record and does not change its cursor cannot show the two versions apart.
208
+ - Text compares by its UTF-8 bytes, as SQLite `BINARY` does. `int64` text compares as 64-bit integers.
209
+ - Each value of a `date`, `date-time`, `date-time-local` or `time-local` field has the same width, so its byte order is its time order.
210
+ - The destination validates each observation, also a version that loses.
211
+
212
+ Stream properties are scalars, or arrays of one scalar type or of objects:
213
+
214
+ - An array field declares `type: 'array'` (or `['array', 'null']`) and an `items` schema. The `items` schema has the type of the element, and its `enum`, range, `minLength` and date `format`.
215
+ - `validateRecords` checks each element. It refuses constraints on the array itself.
216
+ - An array can also hold objects: `items` is `{ type: 'object', properties, required }`, its properties are scalars, and `required` names each of them, as a record names each property. `validateRecords` checks that each object has exactly those fields, each one valid. A page's language copies, `{ language, url }`, are an example; Airbyte's type system nests objects in arrays the same way.
217
+ - Arrays hold lists of values, such as keywords, counts for each day or a page's language copies. A collection whose members are records of their own, with a key and a lifetime, is a different stream of rows.
218
+ - Arrays cannot be keys or cursors.
219
+
220
+ ### String formats
221
+
222
+ A string property can declare its form. Thus a value that a JSON number or a JavaScript `Date` would round keeps its exact value. `validateRecords` accepts one form of each value:
223
+
224
+ | Schema | Value | Example |
225
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------ |
226
+ | `format: 'date-time'`, `precision` 0–9 (3 when not given) | A UTC instant that ends in `Z`, with `precision` fraction digits | `2025-01-02T03:04:05.1234567Z` |
227
+ | `format: 'date-time-local'`, `precision` 0–9 (3) | A date and a time of a wall clock, with no zone | `9999-12-31T23:59:59.9999999` |
228
+ | `format: 'time-local'`, `precision` 0–9 (3) | A time of day with no zone | `23:59:59` |
229
+ | `format: 'date'` | A calendar date | `2024-02-29` |
230
+ | `format: 'int64'` | A signed 64-bit integer with no leading zeros | `-9223372036854775808` |
231
+ | `format: 'decimal'`, optional `precision` and `scale` | An exact decimal: `scale` fraction digits when declared, else no trailing zeros | `922337203685477.5807` |
232
+ | `contentEncoding: 'base64'` | Bytes as padded RFC 4648 base64 | `AAEC/w==` |
233
+
234
+ - `date-time-local`, `time-local`, `int64` and `decimal` are names from the OpenAPI format registry. The `time` format of JSON Schema requires an offset, so it cannot hold a time of day with no offset.
235
+ - `precision` and `scale` have the meaning that they have in SQL.
236
+ - elt refuses a declaration that combines these keywords in a different way, or that puts them on a type that is not a string.
237
+ - Each destination stores a format in a type that keeps it exact. See the mappings of [SQLite](../destinations/sqlite/README.md#columns) and [Postgres](../destinations/postgresql/README.md#columns).
238
+
239
+ ### Names
240
+
241
+ A SQL destination gives each column, table and reader view a name that it can hold. It uses the rule of Airbyte, so a field with any name loads:
242
+
243
+ 1. The compatibility decomposition of Unicode (NFKD) separates each mark, and the destination removes the mark (`spécial` becomes `special`).
244
+ 2. Each sequence of whitespace becomes one `_`.
245
+ 3. Each other character that is not in `[A-Za-z0-9_]` becomes `_` (`名前` becomes `__`). The case stays.
246
+ 4. A name that starts with `_elt_`, in any case, loses its first `_` (`_elt_seq` becomes `elt_seq`), because elt names its own tables and columns with `_elt_`.
247
+ 5. An empty name becomes `_`.
248
+ 6. A name that starts with a digit gets a `_` in front (`1st` becomes `_1st`).
249
+
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`.
252
+ - Postgres compares names with their case. SQLite compares names without ASCII case, so `Foo` and `foo` become `Foo` and `foo_1`.
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
+ - 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.
255
+ - When the rule changes a column name, the column description ends with `Source field: "<field>".` The catalog shows that description, so a reader can find the field.
256
+ - The names come only from the fields of the stream, so each run gives the same names. A change of fields evolves the stored table ([shape changes](#shape-changes)).
257
+ - Schema names and the names of published views do not change, because the SQL of the caller uses them. A destination refuses such a name when it cannot hold it.
258
+
259
+ ## Incremental copies and checkpoints
260
+
261
+ - An incremental copy needs a stable `id` and a [checkpoint store](#checkpoint-stores).
262
+ - The IDs must be different across all connections of a pipeline. Each ID has its own progress.
263
+ - A connection copies each stream one time. To load one stream into two targets, use two connections with two IDs (see [read context](#read-context)).
264
+
265
+ The read:
266
+
267
+ - `Source.read(catalog, states, signal)` gives each incremental stream its saved state, or `null` on its first run. `extract` gets it as `state`.
268
+ - `extract` emits records `{ stream, data }`, deletions `{ type: 'DELETE', stream, key }`, [resets](#resets) `{ type: 'RESET', stream }` and checkpoints `{ type: 'STATE', stream, state }`.
269
+ - Their text must be well-formed Unicode. A string or a field name with a lone surrogate fails its stream, because SQLite would store U+FFFD and Postgres refuses it.
270
+ - The state belongs to the source. It must go into JSON and come back with no change. Destinations do not read it.
271
+
272
+ Each checkpoint is a commit point, as in Airbyte:
273
+
274
+ - The writer commits all that came before the checkpoint, then it acknowledges the checkpoint. The store saves it before the load continues.
275
+ - A run that fails later keeps each checkpoint that it acknowledged, and the next run resumes from the last one.
276
+ - With no acknowledgement there is no progress, also when the source changes its input state.
277
+ - When `extract` throws, `Source.read` does not throw. It reports a `FAILED` stream status for that partition or stream, as the error trace of Airbyte does. The stage of the stream discards what it staged after its last checkpoint, and it commits nothing more until the next checkpoint.
278
+ - A source tells of a failed read with a throw. It never yields nothing in place of a throw.
279
+ - A full refresh has no checkpoints. It commits one time, at the end, and a failure keeps the earlier target.
280
+
281
+ The binding:
282
+
283
+ - The store binds each ID to its copy: the source identity, the `location` of the target and the selected configuration.
284
+ - The store also binds each ID to the shape of its stream: its schema, key and sync modes, without the descriptions.
285
+ - The columns of a target are not part of the binding. Thus a changed field keeps the checkpoint, also when the target declares its columns, and the destination evolves its target.
286
+ - A changed declaration stops the copy with `StreamChangeError` until the host resets or clears it ([shape changes](#shape-changes)).
287
+ - A changed description only updates the comments of the target.
288
+ - A changed copy fails before it extracts. Use a new ID, or reset the progress:
289
+
290
+ ```ts
291
+ await checkpoints.reset('notes-to-sqlite'); // The next read starts from null; the destination data stays.
292
+ await pipeline.run();
293
+ ```
294
+
295
+ - `reset` keeps the loaded rows, as the refresh of Airbyte that keeps records. A reset of append progress can duplicate data. A reset of deduplicated progress reconciles the records that the source gives again.
296
+ - To drop the rows also, [clear the copy](#target-ownership).
297
+ - Do not share a state file between machines that are not related. The parent folder of the file must exist.
298
+
299
+ Delivery is **at least once**:
300
+
301
+ - The store saves a checkpoint after its rows commit, in a different store.
302
+ - When the rows commit and the save fails, the copy reports that failure with the committed counts. The next run starts again from the last saved checkpoint.
303
+ - Append keeps the observations that come again. Deduplication reconciles them.
304
+ - elt has no full refresh that resumes.
305
+
306
+ ### Checkpoint stores
307
+
308
+ The state belongs to the orchestration, not to the destination, as in Airbyte. Thus any store works with any destination, also with a destination that cannot hold state. The replication saves each checkpoint only after the rows of its stream before it committed.
309
+
310
+ - `CheckpointStore.run(bindings, work)` does that protocol for all incremental copies of a pass together. It checks the binding of each copy, clones the input state, and moves forward only on a commit. `reset` and `clear` are also part of it.
311
+ - A store gives a session that holds the lock of each copy for the full pass. A `save` of the session is durable when it resolves.
312
+ - With each checkpoint, the store saves whether a reload is open ([resets](#resets)).
313
+
314
+ | Store | Package |
315
+ | ------------------------------------------ | ------------------------------------------------------------------ |
316
+ | `SQLiteCheckpointStore({ path })` | [elt-sqlite](../destinations/sqlite/README.md#checkpoints) |
317
+ | `PostgresCheckpointStore({ url, schema })` | [elt-postgresql](../destinations/postgresql/README.md#checkpoints) |
318
+
319
+ ## Snapshot streams
320
+
321
+ A source with no change feed can still load incrementally with `diffSnapshot(configuration, records, state, { policy, signal })`. This is a [snapshot differential](https://github.com/JanuaryLabs/zukhruf/tree/main/packages/snapshot-differential#readme) (Labio and Garcia-Molina, VLDB 1996).
322
+
323
+ - The stream declares `sourceDefinedCursor`. It declares `emitsDeletes` when its upstream deletes records. A [forgetting upstream](#forgetting-upstreams) does not declare it.
324
+ - The source gives one complete scan, the earlier state, a deletion policy, and the signal of the run that its `extract` gets:
325
+
326
+ ```ts
327
+ import { ClosedWorld } from '@zukhruf/snapshot-differential';
328
+
329
+ protected override async *extract(configuration, state, partition, context, signal) {
330
+ // The scan lists every record, so a key it does not list was deleted.
331
+ yield* diffSnapshot(configuration, this.scan(configuration.stream), state, {
332
+ policy: new ClosedWorld(),
333
+ signal,
334
+ });
335
+ }
336
+ ```
337
+
338
+ - The state is `{ snapshot: { <JSON of the key values>: <SHA-256 of the record> } }`.
339
+ - Each run emits new or changed records, a `DELETE` for each vanished key that the policy judges deleted, and then the new snapshot as the only `STATE`.
340
+ - Records that did not change make no writes.
341
+ - The first run (state `null`) loads all records and deletes nothing.
342
+
343
+ The policy is a `DeletionPolicy`. It is a Strategy that gives each vanished key a verdict:
344
+
345
+ - `deleted`: the closed-world assumption. The diff emits a `DELETE`.
346
+ - `unknown`: the open-world assumption. The key stays in the snapshot, and the next run judges it again.
347
+ - `expired`: a time to live. The key leaves the snapshot with no `DELETE`.
348
+
349
+ `ClosedWorld` judges each vanished key deleted. `Retention(horizon)` is for [expiring upstreams](#expiring-upstreams). `Coverage(covers)` is for [partly held upstreams](#partly-held-upstreams). A source with a different rule writes its own subclass. A stream that declares `emitsDeletes` must give a policy. A stream that does not declare it gives no policy.
350
+
351
+ - **Replay:** when the checkpoint save fails after the data committed, the next run compares with the older snapshot. It applies the same upserts and deletions again and gets the same rows.
352
+ - **Failures:** an empty scan deletes all rows. The source must throw on a failed read. It must never yield an empty collection in place of the read. The diff refuses a key that comes two times in one scan, so a source that reads windows that overlap must remove the duplicates first.
353
+ - **Cost:** the source still reads all records on each run. Only the writes of the destination become fewer. The state holds one key and a fingerprint of 43 characters for each row, so it grows with the stream.
354
+ - **Stop:** a scan that finds no change yields no message until its state. A scan that never waits for I/O would make all other tasks of the process wait until it ends, also the stop of the host. Thus the diff gives the event loop a turn every 10 ms and checks `signal` at each record or group. A cancelled run stops in moments. It never ends as a complete scan, which could delete the keys that it did not read. `signal` is required, so no source can leave it out.
355
+ - **Reset:** a reset of the checkpoint makes the next run load all records again, but it forgets which rows exist. Rows that the upstream deleted in that time stay. Reset and clear the target together, or run a full refresh with `overwrite`.
356
+ - **Full refresh:** the source calls `diffSnapshot` in the same way in the two sync modes. In a full refresh, the diff checks the declaration of the stream as an incremental copy does. Then it emits each record of the scan, one at a time, as the scan yields it, and it saves no state. The copy replaces its target, so it compares nothing. The CDK of Airbyte does the same: `Stream.read` selects its checkpoint reader by the sync mode, so the mode stays a concern of the framework and never gets to the code of the stream.
357
+
358
+ ### Grouped snapshots
359
+
360
+ Some records come from inputs that the source can fingerprint and not read, such as files whose stat identifies their content. For them, `diffGroupedSnapshot(configuration, groups, state, { policy, signal })` does not read the unchanged inputs.
361
+
362
+ - Each group is `{ key, fingerprint, records() }`: one input, a fingerprint of it, and a function that reads its records.
363
+ - In an incremental copy, a group whose fingerprint is equal to the saved fingerprint of its key keeps its saved rows. The diff does not call its `records()`. The diff reads and compares each other group, and each group whose fingerprint is `null`, record by record.
364
+ - A source that knows that a group did not change can give a kept group, `{ key, kept: true }`, with no fingerprint. The group keeps its saved rows. A kept group that the snapshot does not have fails the diff. A full refresh reads each group, so it refuses a kept group.
365
+ - The state is `{ groups: { <group key>: { fingerprint, snapshot } } }`, with `snapshot` as above. It also has `unknown`, the keys that the policy judged unknown, when there is one.
366
+ - **Deletions:** the policy judges each key that no group gave in this run, kept or read. With `ClosedWorld`, a vanished input deletes its rows, and a changed input deletes the rows that it does not give now. The groups must still come from a complete list: the source lists each input on each run, and it skips only the read.
367
+ - **Fingerprints:** a fingerprint must change each time that the records of the group can change, also when the parse code of the source changes. A version constant in the fingerprint does this. A fingerprint that misses a change keeps old rows, and no error tells of it.
368
+ - **Moves:** a row that moves to a different group is compared with its earlier fingerprint, wherever it was. Thus it loads only when it changed. The diff refuses a key that comes two times in one run, also from a kept group, and a group key that comes two times in one run.
369
+ - **Airbyte:** the [file-based cursor](https://github.com/airbytehq/airbyte-python-cdk/blob/d5536bc78c261a5f7d89595cb811c8bad676e251/airbyte_cdk/sources/file_based/stream/cursor/default_file_based_cursor.py#L82-L111) also skips files that did not change. It uses only the modification time, keeps at most 10,000 files before it uses a time window, and never emits deletions. Groups keep each fingerprint, compare it for equality and not for recency, and delete from the complete list.
370
+
371
+ ### Expiring upstreams
372
+
373
+ Some upstreams keep records only for a time, and then drop them with no deletion. For example, macOS keeps activity for 28 days, and Safari keeps history for the age that the user set. A snapshot diff would delete those rows also. A stream that declares `expiresBy` keeps them. `expiresBy` is the name of a millisecond `date-time` property that is not null. Its source gives each diff a retention from the horizon from which the upstream keeps records: `diffSnapshot(configuration, records, state, { policy: new Retention(horizon), signal })`, and the same for `diffGroupedSnapshot`. A diff of such a stream with no policy fails.
374
+
375
+ - **Expiry:** each snapshot entry also saves the `expiresBy` value of the record (`[fingerprint, expiresBy]`). The policy gets it as `{ key, expiresBy }`. A vanished key with a value before the horizon expired: it leaves the snapshot with no `DELETE`, and its row stays. A vanished key at or after the horizon is deleted. Thus a deletion that the upstream makes in its retention still gets to the destination.
376
+ - **The horizon:** the source sets it on each read from the retention rule of the upstream. It never sets it from the oldest record that it finds, because a "clear all" leaves no old record and would make each deletion an expiry. An earlier horizon misses deletions of the oldest records. A later horizon deletes rows that the upstream only expired. Thus sources set it with a margin inside the retention.
377
+ - **Returns:** a key that comes back after it expired, such as an event that a different device synced late, loads as new.
378
+ - **Bounded state:** expired keys leave the state, so the state grows with what the upstream keeps, not with all that the copy loaded.
379
+ - **Rows outlive the upstream:** a clear of the copy, or a full refresh with overwrite, loses each expired row. No run can read it again. A reset keeps them, and a change of the fields of the stream also keeps them ([shape changes](#shape-changes)). Nothing marks a loaded row as expired.
380
+ - **Precedent:** the [`delete-insert` of dlt](https://github.com/dlt-hub/dlt/blob/1.30.0/dlt/destinations/sql_jobs.py#L200-L234) and [`scd2` with a `merge_key`](https://github.com/dlt-hub/dlt/blob/1.30.0/dlt/destinations/sql_jobs.py#L969-L990) also remove only rows in what a load loaded again. Airbyte deletes only on change data capture markers, and its [refreshes guide](https://github.com/airbytehq/airbyte/blob/0eef98ff7f266392e6d1e1077e80d66971f2a378/docs/platform/operator-guides/refreshes.md#L36-L96) names a source that "does not retain all of its records" as the case where a truncate loses data. In elt, the source limits deletions by time, because it knows the retention of the upstream.
381
+
382
+ ### Partly held upstreams
383
+
384
+ Some upstreams hold only a part of their records, and they tell which part. For example, the Slack app keeps the parts of each conversation that it loaded, and it records their ranges. A message that vanished in a range that the cache still holds was deleted. A message out of all ranges only left the cache. The source gives the diff a `Coverage` with the vanished records that the read vouches for:
385
+
386
+ ```ts
387
+ import { Coverage } from '@zukhruf/snapshot-differential';
388
+
389
+ protected override async *extract(configuration, state, partition, scan, signal) {
390
+ // A vanished message is deleted only where the app still holds the history.
391
+ yield* diffSnapshot(configuration, this.scan(configuration.stream), state, {
392
+ policy: new Coverage(({ key }) => scan.holds(key.workspaceId, key.channelId, key.ts)),
393
+ signal,
394
+ });
395
+ }
396
+ ```
397
+
398
+ - **What the predicate gets:** each key that the snapshot has and that the read does not list, as the fields of the primary key. For an [expiring](#expiring-upstreams) stream it also gets the saved `expiresBy`. It returns whether the read covers that record, which means that the record was deleted.
399
+ - **Keys with no cover:** a key that the read does not cover is unknown. It stays in the snapshot with no `DELETE`, and its row stays. A later read that covers it and does not list it deletes it, for example when the app loads a conversation again without a message that a person deleted in that time. A later read that lists it with no change writes nothing.
400
+ - **State:** the snapshot keeps each unknown key. Thus it grows with the rows that the upstream does not hold now and that no read can judge.
401
+ - **Who decides:** only the source knows what its upstream holds now. Thus the rule is an argument of each read, never a declaration of the stream: what a cache holds changes with each read.
402
+ - **Rows outlive the upstream**, as for expired rows: a clear of the copy, or a full refresh with overwrite, loses each row with no cover.
403
+ - **Precedent:** Airbyte deletes only on change data capture markers, and its Slack source never deletes. The Slack connector of Fivetran reads all history again to find deletes. A read that covers all records does the same.
404
+
405
+ ### Re-read windows
406
+
407
+ Some reads ask for a window, and the next read asks for a part of it again. For example, Search Console reads each day from the last settled day, because Google can still restate those days. Without help, the snapshot would hold each record that the first read loaded, sixteen months of them, and a later read that starts after a day would see the rows of that day as vanished. The source tells the diff which records the next read reads again:
408
+
409
+ ```ts
410
+ // The next read starts at the last settled day.
411
+ yield *
412
+ diffSnapshot(configuration, rows, state, {
413
+ policy: new ClosedWorld(),
414
+ rereads: (record) => String(record.date) >= nextStart,
415
+ signal,
416
+ });
417
+ ```
418
+
419
+ - **What stays:** only the records that `rereads` accepts stay in the saved snapshot. This is the lookback window. Thus the state holds no more than what the next read compares. A record that is not in the snapshot cannot vanish from that read, so its row stays and is never deleted.
420
+ - **What is deleted:** a kept record that the next read does not give now. An example is a query that Google stopped to report for a day that the source reads again, or each row of a day that comes back empty.
421
+ - **Who decides:** the source sets where its next read starts, from its own checkpoint. Thus `rereads` and that start come from the same value. When the source keeps fewer records than the next read covers, rows that the read stopped to give stay. When it keeps more, the diff deletes rows that the read did not ask for.
422
+ - **Precedent:** the Search Console source of Airbyte reads only final data, and it says that its ["syncs do not delete rows"](https://github.com/airbytehq/airbyte/blob/4bc984c55bafae1fdf1f09d9dcc62fbea0a98241/docs/integrations/sources/google-search-console.md#L246-L247). Fivetran syncs a rollback window of seven days again and does not find deletes. The [`delete-insert` of dlt](https://github.com/dlt-hub/dlt/blob/5084786a456038fab4b4e05f50492a718430d0a6/docs/website/docs/general-usage/merge-loading.md#L22-L29) with a merge key replaces at the destination each day that a load brings. A day that comes back empty brings no merge key, so it keeps its rows.
423
+
424
+ ### Scans in parts
425
+
426
+ Some upstreams cannot list all records in one run. For example, an API pages slowly and answers a client that reads too much with "Please wait a few minutes", as Instagram does. A profile of 3,479 posts is 106 pages, and a run reads 10. The source lists each page as one part of a scan that later runs continue. The diff deletes only when the scan ends:
427
+
428
+ ```ts
429
+ for await (const page of profile.pages(after)) {
430
+ yield *
431
+ diffSnapshot(configuration, page.posts, state.diff, {
432
+ policy: new ClosedWorld(),
433
+ continues: page.hasNext, // more parts follow: delete nothing yet
434
+ signal,
435
+ });
436
+ }
437
+ ```
438
+
439
+ - **What a reader sees:** a new or changed record loads with the part that lists it. A record that no part of the scan listed is deleted when the part that ends the scan commits. Until then, each row stays.
440
+ - **State:** while a scan is open, the snapshot also keeps the keys that its parts listed (`listed`). A part is a commit: a run that fails keeps the parts before it, and the next run continues after them.
441
+ - **Lag:** a record that is deleted after a part listed it is deleted when the next scan ends. Two parts can list one key. One part cannot list it two times.
442
+ - **Precedent:** the resumable full refresh of Airbyte keeps its page token only between the attempts of one job, and its truncate refresh keeps each row hidden until the sync ends. Neither lets one scan continue through syncs that succeed. See [ADR 0002 of snapshot-differential](https://github.com/JanuaryLabs/zukhruf/blob/main/packages/snapshot-differential/docs/adr/0002-a-scan-can-be-listed-in-parts.md).
443
+
444
+ ### Forgetting upstreams
445
+
446
+ Some upstreams drop records for reasons that they do not record, so nothing shows a deletion apart from the other reasons. For example, Notification Center deletes a notification when its app withdraws it, when the user clears it, when a newer one replaces it, or when it expires, and all of them are the same `DELETE`. A stream over such an upstream does not declare `emitsDeletes`. The diff then never emits a `DELETE`, and each row that it loaded stays.
447
+
448
+ ```ts
449
+ // Notification Center forgets notifications: no emitsDeletes, so a vanished
450
+ // one keeps its row. Its source diffs each scan as any snapshot stream does.
451
+ const notifications = new Stream({
452
+ ...declaration,
453
+ sourceDefinedCursor: true,
454
+ });
455
+ ```
456
+
457
+ - **What changes:** only deletions. New and changed records load as in each snapshot stream, and a record that did not change writes nothing.
458
+ - **Bounded state:** a vanished key leaves the state, so the state holds what the upstream keeps now. A key that comes back loads as new.
459
+ - **No policy:** such a stream takes no policy. `expiresBy` needs `emitsDeletes`: an expiring upstream deletes in its retention, a partly held upstream deletes in what it holds, and a forgetting upstream never deletes.
460
+ - **Rows outlive the upstream**, as for expired rows: a clear of the copy, or a full refresh with overwrite, loses them. A reset, or a change of the fields of the stream, keeps them. Nothing marks a loaded row as gone from the upstream.
461
+ - **Airbyte:** it deletes destination rows only "if your source supports emitting deleting records (e.g. an CDC database source)" ([incremental append + deduped](https://docs.airbyte.com/platform/using-airbyte/core-concepts/sync-modes/incremental-append-deduped)). An incremental stream with no deletions keeps each row that it loaded. In elt this is a declaration on the stream, not a property of the source.
462
+
463
+ ## Partitioned streams
464
+
465
+ One source can read a stream as several partitions, for example one for each Search Console property or each account. The stream declares `partitionKey`, which is a subset of its `primaryKey`. The source lists the partitions from its configuration, and `extract` gets each partition:
466
+
467
+ ```ts
468
+ protected override partitions(stream) {
469
+ return this.siteUrls.map(siteUrl => ({ siteUrl }));
470
+ }
471
+
472
+ protected override async *extract(configuration, state, partition) {
473
+ // state is the checkpoint of this partition, or null the first time.
474
+ }
475
+ ```
476
+
477
+ `Source.read` calls `extract` one time for each partition, in the listed order, and keeps the state of each partition apart. The checkpoint is `{ partitions: [{ partition, state }] }`, and the library owns it. Each time that a partition saves a checkpoint, the library emits the full envelope: the new state of that partition and the latest state of each other listed partition. Thus each partition commits when it is complete.
478
+
479
+ - **New partition:** it gets `null` and starts from the normal start of the source, for example a backfill of the full history. The other partitions resume.
480
+ - **Removed partition:** it leaves the next checkpoint. Its rows stay. When a later read lists it again, it starts from `null`.
481
+ - **Identity:** the list of partitions is not part of the source identity or of the checkpoint binding. Thus a partition that you add or remove never invalidates the checkpoints of the others.
482
+ - **Rows:** each record and each `DELETE` key must hold the values of its partition. A row that names a different partition, or no partition, fails that partition. The `partitionKey` fields are members of the `primaryKey` of the stream, so the key of a deduplicating copy always has them.
483
+ - **Failures:** as in Airbyte, a partition that fails does not stop the others in an incremental read. The rows of that partition after its last checkpoint are discarded, and it keeps its saved state. Thus the next run tries it again from there. The other partitions commit and move forward. The copy then reports the failed partitions, and the run fails and names each one. elt never skips a failed partition without a report.
484
+ - **Full refresh:** the partitions are read in the same way, with no state. An overwrite replaces the full target with all listed partitions. A partition that fails in a full refresh stops the read of the stream, because a full refresh commits nothing after a failure.
485
+
486
+ `Source.read` asks for the partitions at the start of each read, and a watch asks at its start. Thus a source whose host changes them, for example the inputs of a web stream, gives the list as it is then. The `partitionKey` fields must be different scalar members of the primary key that are not null. The list must have no repeated partition.
487
+
488
+ - **No partition:** the stream reads nothing. Its checkpoint stays as it is. An Airbyte partition router with no values also gives no slices.
489
+
490
+ ## Read context
491
+
492
+ A pass is one read of one connection: one `Source.read(catalog, states, signal)` over each stream that the connection selected, as `read(config, catalog, state)` of Airbyte. `Pipeline.run()` makes one pass for each connection. The source opens one context for the full read, so related streams tell of the same moment of the upstream. Separate reads would let a stream that joins refer to a row that its parent stream never saw.
493
+
494
+ ```ts
495
+ class ChatSource extends Source<ChatDatabase> {
496
+ protected override open(streams, signal, states) {
497
+ return ChatDatabase.open(this.path); // AsyncDisposable, disposed when the read ends
498
+ }
499
+
500
+ protected override async *extract(configuration, state, partition, database) {
501
+ // database is the read's context
502
+ }
503
+ }
504
+ ```
505
+
506
+ - **Contract:** each source implements `open(streams, signal, states)`. It gets the streams that the read covers, the signal of the run and the saved states of the read, which `read` receives. A context whose streams depend on the checkpoint of another stream reads it there, as the web pages source reads the delivery number of each routes stream. An `open` that waits for its upstream gives the signal on, so a cancelled run stops it, as Calendar and Reminders stop their EventKit helper.
507
+ - An upstream with a read transaction holds it (Messages; Notes; Contacts, one time for each account store).
508
+ - An upstream without one reads each selected stream first, in one window with no change, and gives the streams from that snapshot (Calendar and Reminders; see [EventKit consistency](../sdks/apple/eventkit/README.md#consistent-reads)).
509
+ - A source whose streams do not have to agree returns an empty `AsyncDisposableStack` (Search Console).
510
+ - **Stream status:** each stream reads as `STARTED`, then its messages, then `ENDED`. A partition or stream that failed adds `FAILED` with its error. Only `Source.read` makes a `StreamStatus`. A `StreamStatus` that `extract` yields fails its stream with a `TypeError`. So does a message that names a stream other than the stream that the source extracts. Thus a stream can never write into the target of a different stream.
511
+ - **Interleaving:** `protected concurrency` (1 when not set) sets how many streams read at the same time. Their messages interleave. The read asks a stream for its next message only after the consumer took its last message, so the file of a record stays valid until the consumer moves on. A source that sets a higher value reads its context from several extracts together. The partitions of one stream always read one after the other. The streams of a read are one task group: when one throws outside its extract, the signal that the others read with aborts, so a stream that waits for another ends with the read ([ADR 0018](./docs/adr/0018-the-streams-of-a-read-are-one-task-group.md)).
512
+ - **Lifetime:** the context opens when the read starts and closes when it ends, also when it fails. A watch reads one time in each pass and holds nothing between passes, because a long read can block the maintenance of the upstream.
513
+ - **Failures:** a context that cannot open (a refused permission, a missing store) fails each copy of that pass. `PipelineError.cause` is the error. The passes of other connections continue.
514
+ - **One copy for each stream:** a connection copies each stream one time (`Connection <name> copies each stream once`), as the configured catalog of Airbyte lists each stream one time. To load one stream into two targets, use two connections. A connection that copies a stream two times does not read: the run rejects with this `TypeError`, the sync history records a failed attempt with one coverage row for each stream (through its first copy), and `passChannels.failure` publishes the `PassFailed`.
515
+
516
+ ## Stored files and document parsing
517
+
518
+ A stream that supports file transfer, such as `attachments` of Notes, gives a file with each record. A copy declares in its columns what it takes from the file: the parsed text, a stored file, or the bytes in the database.
519
+
520
+ ```ts
521
+ import { Connection, Copy, LocalFiles, Pipeline } from '@eltjs/elt';
522
+ import { SQLiteSyncHistory } from '@eltjs/elt-sqlite';
523
+ import { MacOSDocumentParser } from '@eltjs/source-apple-macos/macos-document-parser';
524
+
525
+ const files = new LocalFiles({ directory: './outputs/attachments' });
526
+ const attachmentCopy = new Copy(
527
+ notes.attachments,
528
+ sqlite.table('attachments', (c) => [
529
+ c.text('id'),
530
+ c.text('noteId'),
531
+ c
532
+ .text('content')
533
+ .from(notes.attachments.file)
534
+ .parse(new MacOSDocumentParser()),
535
+ c.text('attachmentRef').from(notes.attachments.file.store(files)),
536
+ ]),
537
+ {
538
+ syncMode: 'full_refresh',
539
+ destinationSyncMode: 'overwrite',
540
+ },
541
+ );
542
+
543
+ await new Pipeline({
544
+ history: new SQLiteSyncHistory(),
545
+ clock: globalThis,
546
+ connections: [
547
+ new Connection({
548
+ name: 'apple-notes-attachments',
549
+ source: notes,
550
+ destination: sqlite,
551
+ steps: [attachmentCopy],
552
+ }),
553
+ ],
554
+ }).run();
555
+ ```
556
+
557
+ This copy stores the selected metadata of the source, the parsed `content`, and an `attachmentRef` as `TEXT`. The ID of the attachment and the ID of its note stay available for joins.
558
+
559
+ ### Declarations
560
+
561
+ - `notes.attachments.file` is an immutable reference to the file of the source, not a path. The other streams of Notes refuse file access.
562
+ - `.from(file)` selects the original file. `.parse(parser)` asks for its text.
563
+ - `.store(files)` on the file reference selects a file storage, apart from the connection and the destination. Two fields can use different storages, and copies can share one storage and still own their files apart.
564
+ - A stored file and parsed text must be separate fields. `.parse()` on a stored file field is refused.
565
+ - `c.blob('bytes').from(file)` keeps the original bytes in the database. See [elt-sqlite](../destinations/sqlite/README.md#file-bytes-in-the-database) and [elt-postgresql](../destinations/postgresql/README.md#file-bytes-in-the-database). A plain `c.blob('bytes')` reads the existing `bytes` field of a record into an inline value.
566
+ - Original bytes need a `BLOB` column. Parsed text and stored file references need `TEXT`. `.notNull()` and `.primaryKey()` apply to these fields.
567
+ - Types that do not agree, the file reference of a different stream, and fields with the name of a field of the source metadata or of `loaded_at` fail before the copy extracts.
568
+ - A bare table still infers the metadata schema of the source. To keep all metadata beside the file columns, spread `SQLiteColumns.fromSchema(notes.attachments.jsonSchema)` into the columns.
569
+ - All declarations are immutable and do no I/O. A storage that you make and a field that you declare do no I/O either. The copy never writes the source schema again to describe the fields of the destination.
570
+ - One copy can take several named forms of one file. The copy reads each record one time. Fields with the same parser instance share one parse, and fields of the original bytes share one read. Separate copies keep separate extraction and checkpoint progress.
571
+ - To keep no original file, leave out the stored reference. To export no file, leave out all file columns.
572
+
573
+ ```ts
574
+ // Text only, under a destination field name you choose.
575
+ new Copy(
576
+ notes.attachments,
577
+ sqlite.table('attachment_text', (c) => [
578
+ c.text('id'),
579
+ c
580
+ .text('search_text')
581
+ .from(notes.attachments.file)
582
+ .parse(new MacOSDocumentParser()),
583
+ ]),
584
+ );
585
+
586
+ // Original bytes only; no parser runs.
587
+ new Copy(
588
+ notes.attachments,
589
+ sqlite.table('originals', (c) => [
590
+ c.text('id'),
591
+ c.blob('bytes').from(notes.attachments.file),
592
+ ]),
593
+ );
594
+ ```
595
+
596
+ ### How a source gives files
597
+
598
+ - `Source.read()` is the shared template method. A source implements `extract(configuration, state, partition, context, signal)`, and yields metadata, an optional file path, and state.
599
+ - `Source.read()` resolves `configuration.fileReads` into parsed text or a `FileContent` before it yields the record. A `FileContent` is valid until the consumer takes the next record.
600
+ - A path must stay readable until the consumer moves past its record. It can be a staged copy that the source removes after, or the original file when a copy would cost too much. Notes and Messages give their original attachment files.
601
+ - The replication refuses a record that still holds a source file path (`Destinations cannot receive source file paths`).
602
+ - A source names a staged copy with `fileExtension` from `elt`: the extension of the file when it is 1 to 16 letters or digits, else none. The original file name stays in the source metadata.
603
+
604
+ ### Local files
605
+
606
+ `LocalFiles({ directory })` stores the original bytes, and returns their path relative to its folder: `.elt-files/<scope hash>/<content hash><extension>`. This path is the ref of the stored file.
607
+
608
+ - An import that moves with its files keeps each ref, because the identity of `LocalFiles` names no folder. See [ADR 0015](./docs/adr/0015-a-stored-file-s-ref-is-relative-to-its-folder.md).
609
+ - SQLite and Postgres get only that text value. They do not make paths or write files.
610
+ - `LocalFiles` resolves its folder when you make it.
611
+ - It streams each file in chunks of at most 4 MiB into a temporary file (owner-only, `0600`), syncs it, and then publishes it atomically.
612
+ - The scope is the target of the destination, the writer of the copy and the field. The content hash (SHA-256) makes a repeated save stable, and it never overwrites a stored file.
613
+ - The extension comes from `fileExtension`.
614
+ - An empty file is a real file of zero bytes. A source file that is not available gives a null ref.
615
+ - The replication flushes each declared scope one time before its rows commit. It does not flush the folder for each file. A flush of a scope with no files does nothing. New parent folders are synced when they are made.
616
+
617
+ ### Files and rows
618
+
619
+ - The transfer saves the files before it applies the records, and commits the rows before it removes files that are not used now.
620
+ - It asks each destination for the refs that its committed rows keep. Thus an update that the cursor refuses cannot delete the file of the row that won.
621
+ - Append keeps the files of each observation that it keeps. A replace, a delete, an empty overwrite and `Copy.clear()` remove a file only when no row of this scope refers to it.
622
+ - A source failure discards the pending rows and reconciles with the rows that stay.
623
+ - Files and rows do not share a transaction. A failed save stops its rows and its checkpoint.
624
+ - A cleanup failure after a commit keeps the committed data, but the checkpoint does not move. A replay is safe.
625
+ - A failed write or a stopped run can leave files that no row refers to. The next run reconciles them, from the committed refs, before it extracts.
626
+ - A missing referenced file, a ref that is not a file, and a managed folder that is not valid fail with an error.
627
+ - Keep the stored files until you clear the copy and load it again.
628
+
629
+ ### Write a file storage
630
+
631
+ For a different storage, extend the exported `FileStorage`:
632
+
633
+ | Member | Gives |
634
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------- |
635
+ | `identity` | A stable identity. It is part of the checkpoint binding of the copy. |
636
+ | `reference` | A sentence that tells readers what a ref is. Postgres writes it as the comment of the ref column. |
637
+ | `save(scope, content)` | Saves a `FileContent`, and returns a complete, immutable ref. A save again of the same content returns the same ref. |
638
+ | `flush(scope)` | Makes the completed saves durable before their rows commit. It is safe to repeat, also for a scope with no saves. |
639
+ | `retain(scope, references)` | Removes only the objects of this scope that no committed ref names. |
640
+
641
+ - The transfer reads refs as text that it does not interpret. Thus an S3 storage can return its own form of ref, with no change to the destinations.
642
+ - Only `LocalFiles` exists now. Its reconcile scans the kept refs and the folder of the scope at each commit. It keeps no separate index of objects.
643
+
644
+ ### Write a document parser
645
+
646
+ Extend `DocumentParser` with `parse(path, signal?): Promise<string | null>`.
647
+
648
+ - Return `null` when the file has no text that the parser can show. Throw only when the read failed.
649
+ - Give it a stable identity that includes its version and the configuration that matters. Keep the parser and its configuration immutable, and do not change the file.
650
+ - `identityFor(path)` gives the identity of the format that reads one file, or `null` when the parser does not handle it. A composite parser uses it, so that a new audio model does not invalidate the text of other formats.
651
+ - A parser does not depend on a source or a destination.
652
+ - The identity of the parser is part of the checkpoint binding of the copy. A new identity needs a new copy ID or a reset of the checkpoint.
653
+ - Keys and cursors stay metadata fields. A checkpoint is acknowledged only after the rows before it commit.
654
+ - `MacOSDocumentParser` is the parser of the Apple sources. See [source-apple-macos](../sources/apple/macos/README.md#parsing).
655
+
656
+ Airbyte has the same two concepts on the source side: its [file parser Strategy](https://github.com/airbytehq/airbyte-python-cdk/blob/f77450f74def59598cda8e1e9a4e975031710c18/airbyte_cdk/sources/file_based/file_types/file_type_parser.py) reads files, and its [file transfer](https://docs.airbyte.com/platform/using-airbyte/sync-files-and-records) moves the original content with its metadata. The parser of elt gives plain text, not the Markdown of Airbyte. elt does not claim the full format and OCR support of Airbyte, or wire compatibility.
657
+
658
+ ### Attachment text
659
+
660
+ A copy can store files with no parse: an `attachmentRef` column and no `content` column. The import then never waits for a parse, and a file that the parser cannot read fails no copy. The text of the stored files is derived data, not a source, because its input is what the pipeline wrote. Declare it as a [computed dataset](../destinations/sqlite/README.md#declare-a-computed-dataset) with the `DocumentText` rule. The pipeline reads the files as the copies commit them. [Context Compiler](https://github.com/ezzabuzaid/context-compiler/blob/main/docs/connectors-and-hosts.md) keeps an `attachment_text` view in this way, which SQL joins on `attachmentRef = ref`:
661
+
662
+ ```sql
663
+ SELECT a.id, t.content
664
+ FROM attachments a JOIN attachment_text t ON t.ref = a.attachmentRef
665
+ WHERE t.content LIKE '%invoice%';
666
+ ```
667
+
668
+ - A stored file is addressed by its content in its field. Thus one file that is attached in three chats is one stored file. The same bytes that two streams store are two stored files.
669
+ - A parser whose identity changes reads again the files that it handles. A format that the parser stops to handle keeps the text that it read.
670
+ - Query the loaded `content` with SQL, or index it with SQLite FTS5 in the application. A parse makes no search index. elt has no query API and no general transform step.
671
+
672
+ ## Watch for changes
673
+
674
+ Use the same pipeline for continuous copies:
675
+
676
+ ```ts
677
+ const controller = new AbortController();
678
+
679
+ for await (const { connection, outcomes } of pipeline.watch({
680
+ signal: controller.signal,
681
+ })) {
682
+ // This pass's copies have already extracted, loaded, and saved their checkpoints.
683
+ }
684
+
685
+ // Call controller.abort() from your app's stop/shutdown handler.
686
+ ```
687
+
688
+ - The watch checks the declarations of each connection first. Then each connection reads the change signals of its own source, and it runs a pass over the copies whose streams changed.
689
+ - The copies keep their modes. Incremental copies of Notes and Calendar stay incremental, and full refresh copies stay full refresh. A change notice does not give records, and it does not make a full refresh copy incremental.
690
+ - `run()` stays one execution. The hosts of [Context Compiler](https://github.com/ezzabuzaid/context-compiler/blob/main/docs/connectors-and-hosts.md) call the two.
691
+
692
+ The source owns the detection of changes. For example, the [EventKit SDK](../sdks/apple/eventkit/README.md#watch) watches Calendar and Reminders, and the [Notes source](../sources/apple/notes/README.md#watch) watches the store of Notes.
693
+
694
+ Passes and checks:
695
+
696
+ - The passes of one connection run one after the other. The passes of different connections run side by side, and each waits only for the consumer. Thus a slow source never holds back a different source.
697
+ - Changes that come while a pass extracts, or while the caller handles a result, go into the set of pending streams of that connection. They cause one next pass, not a queue with no limit.
698
+ - The first check of a connection reads each gauge at once. Thus each selected stream is changed or failed. The first pass starts only when each interval had its first check, or its first read failed or timed out. Thus the first pass loads each stream that changed, as one pass.
699
+ - Each interval and timeout of the change signals has its own checks. At a check, a stream is changed when one of its gauges changed, else failed when one of them failed, else unchanged.
700
+ - At each check, a stream is current as of that check when it has no pending change, no pass reads it, and its last copy loaded completely. When its source tells that its upstream was last current at an earlier time, the stream is current as of that earlier time.
701
+ - The pipeline gives each check to `checked()` of its sync history. `checked()` records the first check of a connection at once, and after that at most one time each minute. Only then does it ask the source. Thus `current_as_of` keeps moving while nothing changes, and it stops where the upstream stopped.
702
+ - A stream whose change failed to load stays current only as of the time before that change. Thus a failed refresh shows as a `current_as_of` that gets older. A Debezium heartbeat and a Beam watermark on an idle partition show in the same way that a source is caught up.
703
+ - Each pass yields a `Pass`, `{ connection, outcomes }`, after its load and its checkpoints are durable. Each outcome has the committed `count` and `deleted` and its `failures`, which is empty when the copy loaded completely. The counts are accepted observations, also deduplication no-ops and keys that were not there.
704
+ - A pass that did not load completely does not end the watch, as Airbyte keeps the schedule of a connection after a failed sync. The caller sees its failures, and the failed partitions keep their checkpoints. See [ADR 0010](./docs/adr/0010-a-watch-keeps-watching-after-an-incomplete-pass.md).
705
+
706
+ Retries:
707
+
708
+ - A stream whose copy failed for a `system` reason, by any one of its failures, loads again with no change. The platform of Airbyte also tries a failed sync attempt again, with these delays for an attempt that made no progress: 10 seconds after the failure, then 30, 90 and 270 seconds after each next failure in a row.
709
+ - After the fifth failure in a row, the stream waits for the next change of its source, and that change starts the count again.
710
+ - A pass that loads the stream completely stops the count. Records that a failed pass committed do not change the wait.
711
+ - A stream whose copy failed only for `config` reasons does not load again until its source reports a change, because only the user can fix it.
712
+ - A change that the source reports loads its stream at once, also while the stream waits for a retry. A change to a different stream does not bring a retry earlier.
713
+ - Retries stop when the watch stops. While a stream waits, its `current_as_of` does not move.
714
+ - A retry is due when the `Date` of the pipeline's clock reaches its time. When the wall clock is set back, a timer can end before that time: the watch then waits again and does not use the processor.
715
+ - See [ADR 0011](./docs/adr/0011-a-watch-loads-a-stream-again-after-a-system-failure.md).
716
+
717
+ When a change signal fails:
718
+
719
+ - A failed change signal fails only its streams. The other streams of the connection continue to load. A failed stream is not current as of the check.
720
+ - The sync history records the failure one time, when the change signal starts to fail, as a failed pass of its streams with the failure type of the error.
721
+ - After a `system` failure, such as a connection that its upstream dropped, the next check reads the gauge again, as Prometheus scrapes a failed target again at its next interval. A check that reads a new value loads the stream again.
722
+ - After a `config` failure, the watch reads that gauge no more, and its streams stay failed, because only the user can fix it. A new watch reads the gauge again.
723
+ - An upkeep that fails is recorded in the same way, as a failed pass of the streams of the connection. After a `system` failure it runs again at its next interval. After a `config` failure it runs no more in this watch.
724
+ - Change signals that break the contract stop their connection: a change signal names a stream that the connection did not select, a selected stream is in no change signal, the change signals of one stream have two intervals or two timeouts, or an interval or a timeout is not a positive number of milliseconds. A `changeSignals()` that throws also stops its connection. The sync history records it as a failed attempt. The other connections continue.
725
+ - See [ADR 0020](./docs/adr/0020-a-source-gives-its-change-signals-and-a-failed-signal-fails-only-its-streams.md) and [ADR 0021](./docs/adr/0021-a-source-keeps-its-upstream-producing-through-an-upkeep.md).
726
+
727
+ When a watch stops:
728
+
729
+ - When each connection stopped, or the signal aborts, the watch throws the errors of the stopped connections together as a `WatchError`. A `WatchError` is an `AggregateError`. Its message joins entries `Connection <name>: <message>`, and each error has the error that stopped the connection as its `cause`.
730
+ - A `WatchError` holds only failures that the sync history recorded. An error that the sync history throws itself, such as a write that fails or an error of the host code in a history wrapper, is recorded nowhere. It stops each connection at once, and the watch rejects with that error itself.
731
+ - When the history cannot record a failure, the watch rejects with a `SuppressedError`. Its `error` is the error of the history, and its `suppressed` is the failure.
732
+ - The pipeline does not reconcile on a schedule, and it does not disable a connection.
733
+
734
+ Stop:
735
+
736
+ - An abort stops the checks and the upkeeps of each connection. It stops each pass that runs, as `run({ signal })` does: the history records the pass as `cancelled`, and the watch yields it with that outcome. No other pass starts.
737
+ - Each connection then disposes its gauges and waits for its upkeeps to end, for example for the end of a helper process, for at most one second. A gauge or an upkeep that ignores the signal stays behind, so one source cannot hang the stop of its host.
738
+ - A `break` out of the loop also stops the checks and the pass of each connection. The history records that pass as `cancelled`.
739
+ - A new watch reads the change signals of each source again and makes a first pass again, from the saved checkpoints.
740
+ - Change notices are not durable. The limits of the snapshot or the cursor of the source still apply.
741
+
742
+ A custom source:
743
+
744
+ - It declares a `catalog`.
745
+ - It implements `changeSignals(streams, clock)`: the change signals of the selected streams. Each one is a gauge of `@zukhruf/async`, the streams that it tells about, and the interval and timeout of its checks ([ADR 0020](./docs/adr/0020-a-source-gives-its-change-signals-and-a-failed-signal-fails-only-its-streams.md)). It is a declaration: it does no I/O, and each gauge opens what it reads in its first read. The pipeline reads the gauges, and a gauge that fails fails only its streams.
746
+ - It implements `coverage(stream): ExtractionCoverage`, what a pass over the stream asks the upstream for (see [coverage](#coverage)).
747
+ - A source that must keep doing an action while a watch runs, such as keeping an app running when only that app syncs its store, declares `upkeep(clock)` ([ADR 0021](./docs/adr/0021-a-source-keeps-its-upstream-producing-through-an-upkeep.md)).
748
+ - The base `Source` owns `discover()` and `validate()`. Before it extracts, it refuses each stream that is not the same object as the stream of that name in its catalog. The schema of a stream shapes the destination, so it refuses a different stream with the same name.
749
+ - Add rules of the source, such as a cursor field that it requires, in `validateExtraction()`.
750
+ - A gauge that holds a native resource, such as a connection or a helper process, is `Disposable` or `AsyncDisposable`. The pipeline disposes it when the watch stops. A read that throws fails that gauge for that check.
751
+ - `Stream` stays immutable metadata. The load goes through `Source.read`, `Copy` and the writers of the destination.
752
+
753
+ The tests cover real file system events in temporary storage, the watch subscription of the EventKit helper and its stop on abort, and that the destination and the checkpoints show the data before the watch yields the results. The native file system test needs an environment that permits file system notices. The command sandbox of this host reports `EMFILE` also for one watcher of a temporary folder, and the same probe works outside the sandbox.
754
+
755
+ ## Run a pipeline on a clock
756
+
757
+ A pipeline waits and reads the time: a watch waits between its checks, before it loads a failed stream again, and between the runs of an upkeep, and a gauge can wait or read the time. All of these use the `clock` that you give to the pipeline. The pipeline gives its clock to each source and to the destination on each call, so one pipeline runs on one clock. See [ADR 0019](./docs/adr/0019-a-pipeline-waits-and-reads-the-time-on-the-clock-that-its-caller-gives.md).
758
+
759
+ - An app gives `globalThis`.
760
+ - A test gives `createClock()` of `@sinonjs/fake-timers` and moves it with `await clock.tickAsync(ms)`. A test that does not wait gives `globalThis`.
761
+ - A source that reads the time reads it from the clock of the call: `changeSignals(streams, clock)` and `upkeep(clock)` for a watch, and `open(streams, signal, states, clock)` for a read. A source that reads the time in `extract` keeps the clock in its context.
762
+ - A destination reads the time of a load from `load({ clock })`.
763
+ - A sync history records the time that the pipeline gives with each event. See [Sync history](#sync-history).
764
+
765
+ ## Sources and their hosts
766
+
767
+ - Each Apple source is a package in `packages/sources/apple/<name>` (`source-apple-<name>`) with its readers and tests.
768
+ - The readers that several sources share are in `source-apple-macos`. EventKit access and its Swift helper are in `sdk-apple-eventkit` (`packages/sdks/apple/eventkit`).
769
+ - The Context Compiler plugin and its CLI register a connector for each source. Each connector loads into a SQLite import of its own. See [connectors and hosts](https://github.com/ezzabuzaid/context-compiler/blob/main/docs/connectors-and-hosts.md) for their imports, presets, published views and retries.
770
+ - The Google app is the host of the Google sources. See [apps/google](../../apps/google/README.md).
771
+ - Each host records each pass and its declared coverage beside the data that it loads. The Google app uses `PostgresSyncHistory` in the warehouse. The hosts of Context Compiler use `SQLiteSyncHistory` in the `data.sqlite` of each import.
772
+ - A host that knows that no other process runs a pass of its connections, such as the leader of the flight of a connection, makes `new SQLiteSyncHistory({ soleWriter: true })`. See [elt-sqlite](../destinations/sqlite/README.md#sync-history).
773
+ - `Pipeline` puts the failed streams and partitions in its errors.
774
+
775
+ ## Run and failures
776
+
777
+ `Pipeline({ connections, history })` is the orchestrator.
778
+
779
+ Before a read, `run()` and `watch()` validate each declaration of each connection:
780
+
781
+ - The connection names are different, and the copy IDs are different across the pipeline.
782
+ - The copies of each connection: unknown streams, a stream that two copies read, pairs of modes that are not supported, schema declarations that are not valid, and missing keys, cursors, IDs or checkpoint stores.
783
+ - A connection that is not valid stops the full pipeline before it extracts or makes storage. Its [sync history](#sync-history) records a failed attempt for that connection.
784
+ - The validation uses metadata. The run checks storage permissions, existing constraints, target ownership and record values when it executes.
785
+
786
+ `run()` then makes one pass for each connection, all side by side, so a slow source never holds back a different source:
787
+
788
+ - Each pass locks the checkpoint of each incremental copy, opens one load on the destination, and prepares each target before it reads.
789
+ - A copy whose target refuses it (the target of a different writer, or a reader view that the load did not make) fails and reads nothing.
790
+ - A prepare publishes nothing. A target shows no later than the first commit of its stream.
791
+ - One read then covers the copies that are left.
792
+ - As in Airbyte, each copy runs also when a different copy fails, and the pass of each connection runs also when the pass of a different connection fails.
793
+ - Each stream stages its operations apart from the others and commits them at its own checkpoints. Thus an incremental copy that fails keeps what it committed, and it publishes nothing that it staged after its last commit.
794
+ - A full refresh commits one time, when its stream ends, so a failure keeps the earlier target. An empty overwrite empties the target. An empty append keeps the records that are there.
795
+ - There is no rollback across the pipeline.
796
+
797
+ When a copy is not complete, `run()` throws one `PipelineError` after each pass ends:
798
+
799
+ ```ts
800
+ import { PipelineError } from '@eltjs/elt';
801
+
802
+ try {
803
+ await pipeline.run(); // [{ copy, count, deleted }] when every copy completed.
804
+ } catch (error) {
805
+ if (!(error instanceof PipelineError)) throw error; // Preflight and history errors are direct.
806
+ // error.results contains committed counts and stream/partition failures.
807
+ // error.cause is the first failure.
808
+ process.exitCode = 1;
809
+ }
810
+ ```
811
+
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
+ - 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
+ - A count of zero also covers an empty input that committed.
815
+ - 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.
816
+ - When the history cannot record why a connection failed its validation, `run()` and `watch()` reject with a `SuppressedError`. Its `error` is the error of the history, and its `suppressed` is the error of the validation.
817
+
818
+ `run({ signal })` stops a run on request, as Airbyte cancels a sync:
819
+
820
+ - When the signal aborts, each pass applies no more messages. Each copy that still reads drops what it staged after its last commit, keeps what it committed and the checkpoints that it saved with it, and ends `cancelled`. A copy that ended before keeps its outcome.
821
+ - The [sync history](#sync-history) records the pass as `cancelled`, with the reason of the signal as its error. Then `run()` rejects with `signal.reason`, as `fetch` does.
822
+ - `Source.read(catalog, states, signal)` gives the signal to `open(streams, signal, states)` and to each `extract` as its last argument. Thus a source that waits for its upstream can stop at once. A source that ignores it stops at its next message.
823
+ - A [snapshot diff](#snapshot-streams) yields no message while it finds no change, so it stops itself at the next record or group.
824
+
825
+ The destination contract:
826
+
827
+ - Declarations are frozen, and you can use them again.
828
+ - `Destination.createWriter(configuration, target)` selects a strategy of the storage with no I/O.
829
+ - `Destination.load()` opens the one hold of the pass on the storage.
830
+ - `load.prepare(configuration, target, { writer, reloading })` gives each stream its `Stage`. A `Stage` has `apply`, `commit` and `discard`. It also has `fresh`, which is true when the target holds none of the earlier rows of the copy, so the copy reads from no checkpoint; `reloading`, which is true while commits go to a hidden target; and `complete`, which puts that hidden target in the place of the target after the stream ends with no failure.
831
+ - Each destination uses `reloadMode` to decide what to do with the target: load into it, make it, evolve a stored target that the stream does not fit now ([shape changes](#shape-changes)), open a reload for an overwrite whose stored target does not fit now, or continue the reload that the last run of the copy left open. A `RESET` of the full stream also opens a reload.
832
+ - Destinations own connections, files and publication. `Copy` and `Pipeline` have no SQL or file system code of a load.
833
+ - `Source.identity` and `Destination.location(target)` give stable checkpoint bindings. The scope of a stored file is the location of the target, the writer of the copy and the file field.
834
+ - The identity of a source names what it reads, its kind and what changes which records exist (`sql-server:<database>`). It never names where its store is. Thus a store that moves to a different Mac or home folder keeps its checkpoints. A host that reads a second store of one kind keeps it apart with its own checkpoint store.
835
+ - A custom destination names each stored object by its address in the destination, never by where the destination is.
836
+
837
+ The locks and commits of each destination are in [elt-sqlite](../destinations/sqlite/README.md#locks-and-commits) and [elt-postgresql](../destinations/postgresql/README.md#locks-and-commits).
838
+
839
+ Live check on 2026-09-26 (macOS 26.6.2): the Apple exporter ran two times against the real stores.
840
+
841
+ - The first run loaded each stream (Messages 12,573 messages, Calendar 10,941 events) in 203 s.
842
+ - The second run took 168 s. It wrote nothing for Notes, Messages, Contacts and Reminders. For Calendar it wrote only 2 events (with 1 alarm and 2 recurrence rules) that had just come into its window of one year, and 1 account that EventKit listed for the first time.
843
+ - A run that was killed while it wrote published nothing that it staged.
844
+ - The Search Console exporter loaded each stream into the Postgres warehouse on the same day.
845
+
846
+ ## Sync history
847
+
848
+ As the platform of Airbyte keeps the jobs and attempts of each connection apart from each connector, the orchestrator records each pass in a `SyncHistory`, from `elt`. Like a `CheckpointStore`, it belongs to the orchestration. Give it as `Pipeline({ connections, history })`. The SQLite history is in [elt-sqlite](../destinations/sqlite/README.md#sync-history), and the Postgres history is in [elt-postgresql](../destinations/postgresql/README.md#sync-history).
849
+
850
+ The contract:
851
+
852
+ - `validate(connection)` refuses, with no I/O, a connection that the history cannot record. The pipeline calls it when it validates.
853
+ - `install(connections, at)` brings the storage of the history to its declaration. The pipeline calls it when it validates, after `validate` and before a read, on each run and watch. Thus no host calls it, and it is safe to repeat. See [ADR 0013](./docs/adr/0013-the-pipeline-installs-its-sync-history-before-each-run-and-watch.md).
854
+ - `begin(connection, copies, at)` gets each selected copy with its `coverage`. It is durable when it resolves, before the read starts. Thus a pass that never ends stays visible as unfinished, not missing.
855
+ - `begin` returns a `RecordedPass`. `finish(outcomes, upstream, at)` closes it with what each copy loaded and, for each stream, when its source told that its upstream was last current. `fail(error, failureType, at)` closes a pass that gave no outcomes, such as a connection that is not valid or a failed change signal.
856
+ - A pass ends `succeeded`, `partial`, `failed` or, when the signal of its run stopped it, `cancelled`.
857
+ - A history does not read a clock. `at` is the time of the event, which the pipeline reads from its clock: the start of a pass, its end, or the install. A history records `at`, not the time of its database, so the times of a pass and of a check come from one clock. See [ADR 0019](./docs/adr/0019-a-pipeline-waits-and-reads-the-time-on-the-clock-that-its-caller-gives.md).
858
+ - A subclass implements `recordChecks(connection, checks)`, which is durable when it resolves. Each check is a `StreamCheck`, `{ stream, at }`. `checked()` calls it for the first check of a connection at once, and then at most one time each minute, because each record is a write.
859
+ - The helpers `copyStatus`, `passStatus`, `passError` and `passFailureType` get from the outcomes the status (`SyncStatus`), the error text and the failure type of a copy or a pass. `recordedFailure` makes the failure entry that the two histories store. `errorMessage` gives the message of a thrown value: the message of an `Error`, or the value as a string. `DeclaredCopy` and `RecordedPass` type the contract.
860
+
861
+ ```ts
862
+ import {
863
+ PostgresCheckpointStore,
864
+ PostgresDestination,
865
+ PostgresSyncHistory,
866
+ } from '@eltjs/elt-postgresql';
867
+
868
+ const url = 'postgres://warehouse:warehouse@127.0.0.1:55432/warehouse';
869
+ const warehouse = new PostgresDestination({ url, schema: 'apple_notes' });
870
+
871
+ const history = new PostgresSyncHistory({ url });
872
+
873
+ const recorded = new Pipeline({
874
+ history,
875
+ clock: globalThis,
876
+ connections: [
877
+ new Connection({
878
+ name: 'apple-notes',
879
+ source: notes,
880
+ destination: warehouse,
881
+ checkpoints: new PostgresCheckpointStore({ url, schema: 'apple_notes' }),
882
+ steps: [
883
+ new Copy(notes.notes, warehouse.table('raw_notes'), {
884
+ id: 'apple-notes:notes',
885
+ syncMode: 'incremental',
886
+ destinationSyncMode: 'append_dedup',
887
+ }),
888
+ ],
889
+ }),
890
+ ],
891
+ });
892
+ ```
893
+
894
+ ### Pass channels
895
+
896
+ Each pass publishes what it does on `node:diagnostics_channel` channels, as Node asks a module to do (`doc/api/diagnostics_channel.md`) and as Got and undici do for their requests. To show passes while they run, for example in a terminal, subscribe to `passChannels`:
897
+
898
+ | Channel | Message | When |
899
+ | --------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
900
+ | `passChannels.start` (`elt:pass.start`) | `PassStarted` | The history recorded the start of the pass. |
901
+ | `passChannels.progress` (`elt:copy.progress`) | `CopyProgress` | A copy changed: its `status` (`running`, then one time `complete` or `incomplete`), and the records and deletions that the source `emitted` and that the destination `committed` until then. |
902
+ | `passChannels.finish` (`elt:pass.finish`) | `PassFinished` | The history recorded the `outcomes` of the pass. |
903
+ | `passChannels.failure` (`elt:pass.failure`) | `PassFailed` | The history recorded why a connection gave no pass, with its `error` and `failureType`. |
904
+
905
+ ```ts
906
+ import { AsyncLocalStorage } from 'node:async_hooks';
907
+
908
+ import { CopyProgress, passChannels } from '@eltjs/elt';
909
+
910
+ const scope = new AsyncLocalStorage<string>();
911
+ passChannels.progress.subscribe((message) => {
912
+ if (message instanceof CopyProgress && scope.getStore() === 'mail')
913
+ console.log(
914
+ message.copy.from.name,
915
+ message.status,
916
+ message.committed.count,
917
+ );
918
+ });
919
+ await scope.run('mail', () => pipeline.run());
920
+ ```
921
+
922
+ - A subscriber gets each message as `unknown`, and tells it by its class.
923
+ - Each message has its `connection`. A host that runs several pipelines in one process tells its own passes by their async context: it runs each in an `AsyncLocalStorage`, because Node calls a subscriber in the async context of the publisher.
924
+ - The pass runs in the stores that subscribers bind to `passChannels.start` (`passChannels.start.bindStore(store)`): the pipeline starts the pass with `runStores`. Thus a subscriber's `store.getStore()` gives the pass's `PassStarted` in every message that the pass publishes, as the crawls of the pass publish theirs. An OpenTelemetry subscriber uses it to make a crawl's span a child of its pass's span.
925
+ - The pipeline does not wait for a subscriber. A subscriber that throws does not stop the pass, but Node reports the error as an uncaught exception, so a subscriber catches its own errors.
926
+
927
+ ### Failure types
928
+
929
+ Each failure tells whose it is to fix, as the `failure_type` of Airbyte does (`AirbyteTracedException`, `airbyte-python-cdk` `utils/traced_exception.py`). See [ADR 0012](./docs/adr/0012-every-failure-says-whose-it-is-to-fix.md).
930
+
931
+ - Each `LoadFailure` has a `failureType`. The source decides it with `failureType(error): FailureType`.
932
+ - `config` means that the upstream refused what the user set up or granted, so only the user can fix it. `system` is for each other error, also the errors of a destination.
933
+ - A source lists in `configErrors` the error classes that mean `config`. The default `failureType` returns `config` for an instance of a listed class and `system` for each other error. Thus a source that lists nothing calls each failure `system`.
934
+ - The Apple sources list the `*UnavailableError` of their SDKs (the macOS access that the store of the app needs). SQL Server lists its permission and connection errors.
935
+ - A source that decides from more than the class overrides `failureType`. Search Console maps a `401` or `403` from Google.
936
+ - `sync_attempts` and `sync_status` hold the `failure_type` of the pass: `config` when any failure is `config`, `system` for a pass that failed in a different way, and NULL after success, while it runs, or when it was cancelled. Each entry of `extraction_coverage.failures` holds its own.
937
+ - Hosts add access guidance only to a `config` failure. A [watch](#watch-for-changes) loads again only a stream that failed for a `system` reason.
938
+ - elt has no `transient_error`, because nothing reads it yet.
939
+
940
+ Each failure in `extraction_coverage.failures` keeps a `stacktrace` beside its `error` message. The stacktrace is the error as Node renders it, with its stack and each `cause` that it chains. A download that failed on the network has the message `fetch failed`, and its stacktrace tells why, for example `[cause]: Error: connect ECONNREFUSED 127.0.0.1:443` with `code: 'ECONNREFUSED'`. Airbyte keeps the same split: each `FailureReason` of the failure summary of an attempt has a short message and a separate `stacktrace` (`airbyte-platform` `FailureHelper.kt`), and its Python traceback has the chained causes. Each entry is `{ partition, error, failure_type, stacktrace }`:
941
+
942
+ ```sql
943
+ SELECT c.stream, json_extract(f.value, '$.error') AS error, json_extract(f.value, '$.stacktrace') AS stacktrace
944
+ FROM extraction_coverage c, json_each(c.failures) f
945
+ WHERE c.attempt_id = (SELECT max(attempt_id) FROM sync_attempts);
946
+ ```
947
+
948
+ ### Coverage
949
+
950
+ Coverage is what the source tells about its own scope: `coverage(stream): ExtractionCoverage`, with `ExtractionCoverage = { description, selection }`. It is what a pass over that stream asks the upstream for, from the configuration of the source (`startAt` and `endAt` of Calendar, `siteUrls` of Search Console). It never comes from the records that the pass finds. `selection` holds the JSON configuration values that the description refers to. Each custom source implements it.
951
+
952
+ - Coverage is the **configured scope**. It is not the minimum or maximum dates that the pass saw, and it does not prove that the upstream is complete.
953
+ - Each source tells its coverage in its README, for example [Calendar](../sources/apple/calendar/README.md#coverage), [Activity](../sources/apple/activity/README.md#coverage), and `localAppleStoreCoverage` of [source-apple-macos](../sources/apple/macos/README.md#coverage), which most Apple sources share.
954
+
955
+ ### How current a stream is
956
+
957
+ A source also tells how current the upstream of each stream is, with `currentAsOf(stream): Promise<Date | null>`. A replica reports the last change that it replayed (`pg_last_xact_replay_timestamp()`), and a Beam source reports its watermark, in the same way. See [ADR 0014](./docs/adr/0014-a-source-says-how-current-each-stream-s-upstream-is.md).
958
+
959
+ - `null` means that the upstream is current each time that the source reads it. Each source gives its answer. None inherits it.
960
+ - A `Date` means that the upstream itself was current only as of that time. For example, the store of an app that the app writes only while it runs stays as of the last write of the app.
961
+ - The pipeline asks before each pass and records the answer with each copy that loads. `current_as_of` is never later than that time.
962
+ - A copy whose source cannot tell fails with that error and is not read. The other copies of the pass load.
963
+ - Airbyte has no such statement. Its "Data fresh as of" is the time when its last sync ended.
964
+
965
+ Each source tells its answer in its README. Most sources answer `null`. These answer with a time:
966
+
967
+ - [Notes](../sources/apple/notes/README.md#watch), [WhatsApp](../sources/whatsapp/desktop/README.md#watching) and [Safari](../sources/apple/safari/README.md#how-current-the-streams-are): the last write of the store while the app is closed, because only the app syncs it.
968
+ - [Slack](../sources/slack/desktop/README.md#watching), [Teams](../sources/microsoft/teams/README.md#watching) and [Mail](../sources/apple/mail/README.md#watching): always the last write of the store.
969
+ - [Wallet](../sources/apple/wallet/README.md#changes-and-deletions): the last iCloud fetch of passd.
970
+
971
+ ### The four clocks
972
+
973
+ Four clocks have different meanings:
974
+
975
+ - 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
+ - `last_successful_sync_at` tells when a recorded pass ended.
978
+ - `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
+ What the history does not prove:
981
+
982
+ - Table timestamps and the end of a pass do not tell that a watch still checks its upstream.
983
+ - An attempt that stays `running` means only that its end was not recorded, also after a process stopped or could not keep its outcomes.
984
+ - The metadata and the commits of the destination are separate. Metadata that did not end never tells of success.
985
+ - The set up of credentials or of a source before the pipeline is made is not part of the recorded attempt.
986
+ - Missing metadata means unknown. It does not mean empty or current.
987
+
988
+ ## Declare derived datasets on the pipeline
989
+
990
+ ```ts
991
+ import { DocumentText, LocalFiles, Pipeline } from '@eltjs/elt';
992
+ import { ComputedDataset, SQLiteSyncHistory, View } from '@eltjs/elt-sqlite';
993
+ import { MacOSDocumentParser } from '@eltjs/source-apple-macos/macos-document-parser';
994
+
995
+ const pipeline = new Pipeline({
996
+ history: new SQLiteSyncHistory(),
997
+ connections: [connection],
998
+ derived: [
999
+ // Computed on write: a rule makes one output for each stored file.
1000
+ new ComputedDataset({
1001
+ into: sqlite,
1002
+ name: 'document_text',
1003
+ description: 'The text of each stored document.',
1004
+ from: [documents],
1005
+ compute: new DocumentText({
1006
+ parser: () => new MacOSDocumentParser(),
1007
+ files: files.directory,
1008
+ column: 'documentRef',
1009
+ }),
1010
+ }),
1011
+ // Computed on read: SQLite runs the query at each read.
1012
+ new View({
1013
+ into: sqlite,
1014
+ name: 'document_count',
1015
+ description: 'How many documents loaded.',
1016
+ columns: { n: 'Number of documents.' },
1017
+ sql: 'SELECT count(*) AS n FROM documents',
1018
+ }),
1019
+ ],
1020
+ });
1021
+ ```
1022
+
1023
+ `connection` copies a stream into the table `documents`, and `files` is the `LocalFiles` that keeps its stored files. Each kind of derived dataset is a class of a destination package. The SQLite destination has `View` and `ComputedDataset`; see [elt-sqlite](../destinations/sqlite/README.md). The Postgres destination has no kinds yet. See [ADR 0001](./docs/adr/0001-derived-data-is-declared-on-the-pipeline.md).
1024
+
1025
+ ## When the pipeline refreshes
1026
+
1027
+ - **Each commit** of a copy asks for a refresh in the background, in a run and in a watch. Thus a long pass shows its derived data while it reads, from what its checkpoints committed. One refresh runs at a time. A commit while a refresh runs asks for one more refresh after it. No pass waits for a refresh. See [ADR 0017](./docs/adr/0017-each-commit-asks-for-a-refresh.md).
1028
+ - **A run** waits for the refresh that runs after its passes, and then refreshes each derived dataset one more time, also when some copies failed. A background refresh that failed rejects the run after its passes, in place of that last refresh; else the last refresh gives the run its dataset failure. Then the run rejects with `PipelineError`. See [ADR 0002](./docs/adr/0002-a-run-refreshes-its-derived-data-also-when-copies-failed.md).
1029
+ - **A watch** also asks for a refresh after each pass. A dataset failure stops the watch. See [ADR 0003](./docs/adr/0003-a-watch-refreshes-in-the-background-one-refresh-at-a-time.md).
1030
+ - The pipeline refreshes the derived datasets in the order of the list. A view that comes after a computed dataset can read the view of that dataset.
1031
+ - A run does not refresh after an abort, or after an error that no sync history recorded. A stop that comes after the passes only stops the refresh. The next refresh continues the work.
1032
+
1033
+ ## Write a rule
1034
+
1035
+ A computed dataset gives each input to its rule. A rule is a class that extends `Rule<Input, Output>`:
1036
+
1037
+ | Member | Gives |
1038
+ | ------------------------- | ---------------------------------------------------------------------------------------------------- |
1039
+ | `key` | the name and the description of the key column, for example `ref` |
1040
+ | `output` | the columns that the rule adds to each output, each `text` or `blob`, with a description |
1041
+ | `batch` | how many inputs one call of `compute` takes, for example the texts of one model call |
1042
+ | `concurrency` | how many calls of `compute` run at the same time, for example files that are parsed side by side |
1043
+ | `read(row)` | the key and the input of one loaded row, or `null` when the row has no input |
1044
+ | `identity(input)` | the rule identity of the input, or `null` when the rule does not handle it |
1045
+ | `compute(inputs, signal)` | one output for each input, in order: `{ value }`, `{ empty: true }`, `{ error }` or `{ gone: true }` |
1046
+
1047
+ The dataset makes an output again when its input or its rule identity changes. See [ADR 0005](./docs/adr/0005-an-output-is-made-again-when-its-rule-identity-changes.md).
1048
+
1049
+ Two rules exist:
1050
+
1051
+ | Rule | Package | Input | Output |
1052
+ | ----------------------------------------- | ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
1053
+ | `DocumentText({ parser, files, column })` | `@eltjs/elt` | the ref in `column`, relative to the folder `files` | `content`, the text that a `DocumentParser` reads. The rule identity is the parser of the format of the file |
1054
+ | `Embedding({ model, key, fields })` | `@eltjs/embedding` | the text of `fields`, with a line break between them | `vector`, from an AI SDK embedding model. The rule identity is the provider and the id of the model |
1055
+
1056
+ `DocumentText` asks for its parser at each refresh, so a parser that the host changed, for example a new speech model, applies from the next refresh.
1057
+
1058
+ ## When a derived dataset fails
1059
+
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.
1062
+ - **A copy that failed** keeps its last committed rows. The derived data of those rows stays as it was.
1063
+
1064
+ ## The same concepts in other systems
1065
+
1066
+ | Here | Elsewhere |
1067
+ | -------------------------- | ---------------------------------------------------------------------------- |
1068
+ | Connection | A connection of Airbyte |
1069
+ | Checkpoint | A state message of Airbyte, which is a commit point |
1070
+ | Reset that keeps the rows | A refresh of Airbyte that keeps records |
1071
+ | Clear | The Clear of Airbyte |
1072
+ | Failure type | The `failure_type` of Airbyte |
1073
+ | Current as of | A Debezium heartbeat, or a Beam watermark |
1074
+ | Derived dataset | Derived data (Kleppmann); a derivation (Estuary Flow); an asset (Dagster) |
1075
+ | View and computed dataset | A dbt model that is materialized as a view, or as a table |
1076
+ | Computed dataset with rule | A pgai vectorizer, which keeps an embedding of each row in step with the row |
1077
+ | Rule identity | The model of a pgai vectorizer: when it changes, pgai embeds the rows again |
1078
+ | Rule failure | A row of `ai.vectorizer_errors` in pgai |
1079
+ | One refresh in the queue | A build watcher, which keeps one rebuild in its queue while a rebuild runs |
1080
+
1081
+ Airbyte has no step after a load. elt has one, because its hosts have no orchestrator.
1082
+
1083
+ ## Decisions
1084
+
1085
+ - [ADR 0001](./docs/adr/0001-derived-data-is-declared-on-the-pipeline.md): derived data is declared on the pipeline.
1086
+ - [ADR 0002](./docs/adr/0002-a-run-refreshes-its-derived-data-also-when-copies-failed.md): a run refreshes its derived data also when copies failed.
1087
+ - [ADR 0003](./docs/adr/0003-a-watch-refreshes-in-the-background-one-refresh-at-a-time.md): a watch refreshes in the background, one refresh at a time.
1088
+ - [ADR 0004](./docs/adr/0004-a-rule-failure-stays-on-its-output.md): a rule failure stays on its output.
1089
+ - [ADR 0005](./docs/adr/0005-an-output-is-made-again-when-its-rule-identity-changes.md): an output is made again when its rule identity changes.
1090
+ - [ADR 0006](./docs/adr/0006-a-stream-whose-fields-change-keeps-its-rows-and-its-checkpoint.md): a stream whose fields change keeps its rows and its checkpoint.
1091
+ - [ADR 0007](./docs/adr/0007-a-stream-that-starts-over-reloads-into-a-hidden-target.md): a stream that starts over reloads into a hidden target.
1092
+ - [ADR 0008](./docs/adr/0008-a-target-has-one-writer.md): a target has one writer.
1093
+ - [ADR 0009](./docs/adr/0009-a-target-is-named-by-its-address-inside-the-destination.md): a target is named by its address inside the destination.
1094
+ - [ADR 0010](./docs/adr/0010-a-watch-keeps-watching-after-an-incomplete-pass.md): a watch keeps watching after an incomplete pass.
1095
+ - [ADR 0011](./docs/adr/0011-a-watch-loads-a-stream-again-after-a-system-failure.md): a watch loads a stream again after a system failure.
1096
+ - [ADR 0012](./docs/adr/0012-every-failure-says-whose-it-is-to-fix.md): every failure says whose it is to fix.
1097
+ - [ADR 0013](./docs/adr/0013-the-pipeline-installs-its-sync-history-before-each-run-and-watch.md): the pipeline installs its sync history before each run and watch.
1098
+ - [ADR 0014](./docs/adr/0014-a-source-says-how-current-each-stream-s-upstream-is.md): a source says how current the upstream of each stream is.
1099
+ - [ADR 0015](./docs/adr/0015-a-stored-file-s-ref-is-relative-to-its-folder.md): a stored file's ref is relative to its folder.
1100
+ - [ADR 0016](./docs/adr/0016-a-watch-watches-again-after-its-watcher-fails-for-a-system-reason.md): a watch watches again after its watcher fails for a system reason. [ADR 0020](./docs/adr/0020-a-source-gives-its-change-signals-and-a-failed-signal-fails-only-its-streams.md) supersedes it in part.
1101
+ - [ADR 0017](./docs/adr/0017-each-commit-asks-for-a-refresh.md): each commit asks for a refresh.
1102
+ - [ADR 0018](./docs/adr/0018-the-streams-of-a-read-are-one-task-group.md): the streams of a read are one task group.
1103
+ - [ADR 0019](./docs/adr/0019-a-pipeline-waits-and-reads-the-time-on-the-clock-that-its-caller-gives.md): a pipeline waits and reads the time on the clock that its caller gives.
1104
+ - [ADR 0020](./docs/adr/0020-a-source-gives-its-change-signals-and-a-failed-signal-fails-only-its-streams.md): a source gives its change signals, and a failed signal fails only its streams.
1105
+ - [ADR 0021](./docs/adr/0021-a-source-keeps-its-upstream-producing-through-an-upkeep.md): a source keeps its upstream producing through an upkeep.
1106
+
1107
+ ## Development
1108
+
1109
+ Run the commands from the workspace root.
1110
+
1111
+ ```sh
1112
+ npx nx run elt:test # builds, then runs src/index.test.ts
1113
+ npx nx run elt:typecheck # formats, lints, then type checks
1114
+ npx nx run elt:build # compiles src/ to dist/
1115
+ ```
1116
+
1117
+ The files of the pipeline:
1118
+
1119
+ ```
1120
+ src/core/
1121
+ pipeline.ts Pipeline: run, watch, retries, and the refreshes at each commit
1122
+ replication.ts one pass: checkpoints, the load and its stages
1123
+ connection.ts Connection
1124
+ copy.ts Copy, its outcome and its progress
1125
+ copy-configuration.ts the modes, keys, cursor and dedup policy of a copy
1126
+ source.ts Source: read, watch, coverage, currentAsOf, failureType
1127
+ stream.ts Stream: the metadata of one stream
1128
+ destination.ts Destination and Load: the port of each destination
1129
+ reload-mode.ts reloadMode: load, make, evolve or reload a target
1130
+ snapshot.ts diffSnapshot and diffGroupedSnapshot
1131
+ partition.ts the checkpoint of a partitioned stream
1132
+ identifiers.ts Identifiers: the naming rule
1133
+ file-read.ts FileRead and FileReference: the file declarations of a copy
1134
+ file-transfer.ts the save, flush and retain of stored files around each commit
1135
+ file-storage.ts FileStorage: the port of each file storage
1136
+ document-parser.ts DocumentParser: the port of each parser
1137
+ formats/ the string formats
1138
+ src/storage/
1139
+ local-files.ts LocalFiles
1140
+ src/state/
1141
+ checkpoint-store.ts CheckpointStore: the checkpoint protocol
1142
+ sync-history.ts SyncHistory and its helpers
1143
+ sync-history-relations.ts the descriptions of the history views
1144
+ ```
1145
+
1146
+ The files of derived data:
1147
+
1148
+ ```
1149
+ src/core/
1150
+ derived-dataset.ts DerivedDataset: the port of each kind
1151
+ derived-refreshes.ts the refreshes of a watch, one at a time
1152
+ rule.ts Rule, RuleResult, LoadedRow, RuleColumn
1153
+ document-text.ts DocumentText: the text of each stored file
1154
+ ```