@eltjs/elt 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -0,0 +1,546 @@
1
+ import { delay } from '@zukhruf/async';
2
+ import { errorMessage, } from '../state/sync-history.js';
3
+ import { Connection } from './connection.js';
4
+ import { describeFailures, } from './copy.js';
5
+ import { DerivedRefreshes } from './derived-refreshes.js';
6
+ import { interleave } from './interleave.js';
7
+ import { PassFailed, PassFinished, PassStarted, passChannels, } from './pass-channels.js';
8
+ import { replicate } from './replication.js';
9
+ import { brokenSignals, watchSignals } from './signal-watch.js';
10
+ // How long a stopping watch waits for a source to finish stopping, such as
11
+ // ending a helper process, before it leaves the source behind: a source that
12
+ // ignores its signal must not hang its host's shutdown, as a grace period
13
+ // bounds a process's SIGTERM before SIGKILL.
14
+ const stopGraceMs = 1000;
15
+ // When a watch loads a stream again, with no change, after its copy failed
16
+ // for a system reason, as Airbyte's platform retries a failed sync attempt:
17
+ // 10 s after the first failure and three times longer after each further one
18
+ // in a row, at most 30 minutes, until 5 in a row failed; the source's next
19
+ // change then starts over. Airbyte also stops at 10 failures in all, which
20
+ // binds only once attempts that made progress break up a run of failures; a
21
+ // watch does not count progress. As in Airbyte, a config failure is never
22
+ // retried: only the user can fix it, and the source's next change loads it.
23
+ // https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-workers/src/main/kotlin/io/airbyte/workers/runtime/AirbyteWorkerRetryConfig.kt#L9-L13
24
+ // https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-commons-temporal/src/main/kotlin/io/airbyte/commons/temporal/scheduling/retries/BackoffPolicy.kt#L32-L42
25
+ // https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-commons-temporal/src/main/kotlin/io/airbyte/commons/temporal/scheduling/retries/RetryManager.kt#L94-L98
26
+ // https://github.com/airbytehq/airbyte-platform/blob/f69abdf9567f1d496fdb7b69e2265e0104c5f2d3/airbyte-workers/src/main/kotlin/io/airbyte/workers/temporal/scheduling/ConnectionManagerWorkflowImpl.kt#L534
27
+ const retryLimit = 5;
28
+ const retryDelayMs = (failures) => Math.min(10_000 * 3 ** (failures - 1), 30 * 60_000);
29
+ // A run that did not load completely, as its sync history recorded. Like
30
+ // Airbyte, every copy still ran; results holds each one's committed counts and
31
+ // failures, and cause is the first failure.
32
+ export class PipelineError extends AggregateError {
33
+ name = 'PipelineError';
34
+ results;
35
+ constructor(results) {
36
+ const incomplete = results.filter(({ failures }) => failures.length > 0);
37
+ const errors = incomplete.flatMap(({ failures }) => failures.map(({ error }) => error));
38
+ super(errors, `The following copies did not load completely: ${incomplete.map(describeFailures).join('; ')}`, { cause: errors[0] });
39
+ this.results = Object.freeze([...results]);
40
+ }
41
+ }
42
+ // A watch that ended, once every connection stopped or its signal aborted,
43
+ // with stopped connections: each error names a connection whose change
44
+ // signals broke the contract, and the sync history recorded why.
45
+ export class WatchError extends AggregateError {
46
+ name = 'WatchError';
47
+ }
48
+ // The orchestrator. Connections are independent: each pass reads one
49
+ // connection's source into its destination, and passes of different
50
+ // connections run side by side, so a slow source never holds back another.
51
+ export class Pipeline {
52
+ connections;
53
+ history;
54
+ // In declared order, which is the order they refresh in.
55
+ derived;
56
+ #clock;
57
+ constructor({ connections, history, derived, clock, }) {
58
+ if (!connections.every((connection) => connection instanceof Connection))
59
+ throw new TypeError('Pipeline connections must be Connection declarations');
60
+ this.connections = Object.freeze([...connections]);
61
+ this.history = history;
62
+ this.derived = Object.freeze([...(derived ?? [])]);
63
+ this.#clock = clock;
64
+ Object.freeze(this);
65
+ }
66
+ // Airbyte's Clear for these copies: empties each target and removes its checkpoint.
67
+ async clear(steps = this.connections.flatMap((connection) => connection.steps)) {
68
+ this.#assertDistinct();
69
+ for (const connection of this.connections)
70
+ connection.validate();
71
+ for (const copy of steps) {
72
+ const connection = this.connections.find(({ steps }) => steps.includes(copy));
73
+ if (connection === undefined)
74
+ throw new TypeError("Only this pipeline's copies can be cleared");
75
+ await copy.clear(connection.source, connection.destination, connection.checkpoints);
76
+ }
77
+ }
78
+ // Once signal aborts, each pass stops at its next message and is recorded as
79
+ // cancelled, and the run rejects with the signal's reason, as fetch does. A
80
+ // stop that comes after the passes ended only stops the derived data
81
+ // catching up: the run ends as its passes did, and the next refresh goes on.
82
+ // While the passes read, each commit asks for a refresh in the background,
83
+ // so derived data follows a long pass. A background refresh's failure
84
+ // stops no pass, and rejects the run once the passes ended, as the failure
85
+ // of a micro-batch fails a Spark query; otherwise the run refreshes once
86
+ // more after the passes, and that refresh's failure rejects it.
87
+ async run(options) {
88
+ const signal = options?.signal;
89
+ await this.#declare();
90
+ const refreshes = new DerivedRefreshes(this.derived, this.connections, signal, () => { });
91
+ let passes;
92
+ try {
93
+ passes = await Promise.allSettled(this.connections.map((connection) => this.#pass(connection, connection.steps, signal, () => refreshes.request('commit'))));
94
+ }
95
+ finally {
96
+ await refreshes.settled();
97
+ }
98
+ // A pass rejects only with an error no history recorded, such as the
99
+ // history's own; the run rejects with it once every other pass ended.
100
+ const unrecorded = passes.find((pass) => pass.status === 'rejected');
101
+ if (unrecorded !== undefined)
102
+ throw unrecorded.reason;
103
+ const results = passes.flatMap((pass) => pass.status === 'fulfilled' ? pass.value.outcomes : []);
104
+ if (results.some(({ cancelled }) => cancelled))
105
+ throw signal?.reason;
106
+ // Derived data follows what the copies committed, also when some copies
107
+ // failed; a dataset's own failure rejects the run as itself, since no
108
+ // history recorded it.
109
+ if (refreshes.failure !== undefined)
110
+ throw refreshes.failure.error;
111
+ await refreshes.refresh();
112
+ if (results.some(({ failures }) => failures.length > 0))
113
+ throw new PipelineError(results);
114
+ return results.map(({ failures: _, cancelled: __, ...result }) => result);
115
+ }
116
+ // Each connection reads the change signals of its own source and runs a
117
+ // pass for what changed. A failed change signal fails only its streams, and
118
+ // its failure is recorded (ADR 0020); a connection whose change signals
119
+ // break the contract stops alone, and its failure is recorded; the rest keep
120
+ // watching. Once every connection stopped, or signal aborts, the recorded
121
+ // failures are thrown together as a WatchError. An error the sync history
122
+ // could not record, or a derived dataset's own failure, stops every
123
+ // connection and rejects as itself.
124
+ async *watch({ signal, }) {
125
+ await this.#declare();
126
+ if (signal.aborted)
127
+ return;
128
+ const controller = new AbortController();
129
+ const watching = AbortSignal.any([signal, controller.signal]);
130
+ const stopped = [];
131
+ const refreshes = new DerivedRefreshes(this.derived, this.connections, watching, (error) => controller.abort(error));
132
+ const passes = interleave(this.connections
133
+ .filter(({ steps }) => steps.length > 0)
134
+ .map((connection) => this.#watch(connection, watching, controller, stopped, () => refreshes.request('commit'))), Number.POSITIVE_INFINITY);
135
+ // Advanced by hand: yield* would forward a consumer's early return to the
136
+ // merged loops before the abort that lets idle ones finish.
137
+ try {
138
+ for (let next = await passes.next(); !next.done; next = await passes.next()) {
139
+ refreshes.request('pass');
140
+ yield next.value;
141
+ }
142
+ }
143
+ finally {
144
+ controller.abort();
145
+ await passes.return(undefined);
146
+ await refreshes.settled();
147
+ }
148
+ if (refreshes.failure !== undefined)
149
+ throw refreshes.failure.error;
150
+ if (stopped.length > 0)
151
+ throw new WatchError(stopped, stopped.map(errorMessage).join('; '));
152
+ }
153
+ async *#watch(connection, signal,
154
+ // Stops every connection's watch.
155
+ everyone, stopped, committed) {
156
+ const controller = new AbortController();
157
+ const watching = AbortSignal.any([signal, controller.signal]);
158
+ const streams = new Map(connection.steps.map((copy) => [copy.from.name, copy.from]));
159
+ const pending = new Set();
160
+ // A check vouches for a stream only when nothing changed it, no pass is
161
+ // reading it, and its last copy loaded completely: a stream whose change
162
+ // failed to load stays current only as of before that change.
163
+ const loading = new Set();
164
+ const loaded = new Set();
165
+ // Each stream whose copy last failed for a system reason: how many times
166
+ // in a row, and when the watch loads it again.
167
+ const retries = new Map();
168
+ let wake = Promise.withResolvers();
169
+ // An abort wakes the loop below, whether or not the source heeds it.
170
+ watching.addEventListener('abort', () => wake.resolve(), { once: true });
171
+ let finished = false;
172
+ // Change signals that cannot be read as declared, which the history
173
+ // records once the connection stopped, and a check the history could not
174
+ // record, which stops the whole watch.
175
+ let failure;
176
+ let unrecorded;
177
+ // Change signals that break the contract stop their connection: watching
178
+ // them again would only break it again.
179
+ const broken = (message) => {
180
+ failure = { error: new TypeError(message) };
181
+ };
182
+ // Each stream whose change signal failed, from the check that saw it fail
183
+ // until a check reads it again: the history records it one time.
184
+ const failing = new Set();
185
+ // When a check last found each stream unchanged, so each check that the
186
+ // history records vouches for each stream as of its own last check.
187
+ const unchangedAt = new Map();
188
+ // Each stream that a check has told about. As an informer lists before it
189
+ // watches, the first pass waits until every stream had its first check:
190
+ // the first pass reads all streams as one task group, and the first value
191
+ // of each gauge is the baseline for the next checks (ADR 0020).
192
+ const told = new Set();
193
+ // Runs an upkeep when the watch starts and then each interval until the
194
+ // watch stops. A failure is recorded one time for each run of failures, as
195
+ // a failed pass of the streams that the watch keeps; after a config
196
+ // failure the upkeep runs no more in this watch, as a gauge does (ADR 0021).
197
+ const keep = async ({ interval, run }) => {
198
+ for (let failed = false; !watching.aborted;) {
199
+ try {
200
+ await run(watching);
201
+ failed = false;
202
+ }
203
+ catch (error) {
204
+ if (watching.aborted)
205
+ return;
206
+ if (!failed) {
207
+ try {
208
+ await this.#recordFailure(connection, error, [...streams.keys()]);
209
+ }
210
+ catch (suppressed) {
211
+ unrecorded = { error: suppressed };
212
+ wake.resolve();
213
+ return;
214
+ }
215
+ }
216
+ failed = true;
217
+ if (connection.source.failureType(error) === 'config')
218
+ return;
219
+ }
220
+ await delay(interval, { clock: this.#clock, signal: watching }).catch(() => undefined);
221
+ }
222
+ };
223
+ // The pipeline reads the source's change signals and decides what each
224
+ // outcome means (ADR 0020).
225
+ const receive = (async () => {
226
+ const keeping = [];
227
+ try {
228
+ // A gauge is the pipeline's from the moment the source gives it, so a
229
+ // source that fails partway through its signals leaves none open. A
230
+ // gauge that stops a helper process can take a moment to stop: the
231
+ // watch waits for it, within the grace of a stopping watch.
232
+ await using gauges = new AsyncDisposableStack();
233
+ const owned = new Set();
234
+ const signals = [];
235
+ try {
236
+ for (const signal of connection.source.changeSignals([...streams.values()], this.#clock)) {
237
+ signals.push(signal);
238
+ if (owned.has(signal.gauge))
239
+ continue;
240
+ owned.add(signal.gauge);
241
+ if (isDisposable(signal.gauge))
242
+ gauges.use(signal.gauge);
243
+ }
244
+ }
245
+ catch (error) {
246
+ failure = { error };
247
+ return;
248
+ }
249
+ const contract = brokenSignals(signals, [...streams.values()]);
250
+ if (contract !== undefined) {
251
+ broken(contract);
252
+ return;
253
+ }
254
+ keeping.push(...[...connection.source.upkeep(this.#clock)].map(keep));
255
+ try {
256
+ for await (const outcomes of watchSignals(signals, {
257
+ signal: watching,
258
+ clock: this.#clock,
259
+ failureType: (error) => connection.source.failureType(error),
260
+ })) {
261
+ const at = this.#now();
262
+ for (const [name, outcome] of outcomes) {
263
+ told.add(name);
264
+ if (outcome.state === 'failed') {
265
+ unchangedAt.delete(name);
266
+ if (failing.has(name))
267
+ continue;
268
+ failing.add(name);
269
+ try {
270
+ await this.#recordFailure(connection, outcome.error, [name]);
271
+ }
272
+ catch (error) {
273
+ unrecorded = { error };
274
+ return;
275
+ }
276
+ continue;
277
+ }
278
+ failing.delete(name);
279
+ if (outcome.state === 'changed') {
280
+ unchangedAt.delete(name);
281
+ pending.add(name);
282
+ }
283
+ else
284
+ unchangedAt.set(name, at);
285
+ }
286
+ if (pending.size > 0 && told.size === streams.size)
287
+ wake.resolve();
288
+ const current = [...streams.values()].flatMap((stream) => {
289
+ const checkedAt = unchangedAt.get(stream.name);
290
+ return checkedAt !== undefined &&
291
+ loaded.has(stream.name) &&
292
+ !pending.has(stream.name) &&
293
+ !loading.has(stream.name)
294
+ ? [{ stream, at: checkedAt }]
295
+ : [];
296
+ });
297
+ if (current.length > 0)
298
+ try {
299
+ await this.history.checked(connection, at, () => checked(connection.source, current));
300
+ }
301
+ catch (error) {
302
+ unrecorded = { error };
303
+ return;
304
+ }
305
+ }
306
+ }
307
+ catch (error) {
308
+ // A change watcher refuses an interval or a timeout that is not a
309
+ // positive number of milliseconds: a declaration that breaks the
310
+ // contract.
311
+ if (!watching.aborted)
312
+ failure = { error };
313
+ }
314
+ }
315
+ finally {
316
+ // The checks ended, so the connection stops watching: its upkeeps
317
+ // stop too, after its gauges were disposed, so an upkeep that is slow
318
+ // to stop keeps no gauge open.
319
+ controller.abort();
320
+ await Promise.all(keeping);
321
+ finished = true;
322
+ wake.resolve();
323
+ }
324
+ })();
325
+ try {
326
+ while (!watching.aborted &&
327
+ failure === undefined &&
328
+ unrecorded === undefined) {
329
+ // Retries end with the change signals, as their changes do.
330
+ if (!finished)
331
+ for (const [name, { at }] of retries)
332
+ if (at <= this.#clock.Date.now())
333
+ pending.add(name);
334
+ if (pending.size === 0) {
335
+ if (finished)
336
+ break;
337
+ // Each wait has its own wake. Each thing that wakes the loop also
338
+ // shows in the checks above, so a wake from before this wait has
339
+ // nothing to tell, and a retry timer that ends before the Date of
340
+ // its retry makes the loop wait again, not spin.
341
+ const woken = (wake = Promise.withResolvers());
342
+ const due = Math.min(...[...retries.values()].map(({ at }) => at));
343
+ const retrying = Number.isFinite(due)
344
+ ? this.#clock.setTimeout(() => woken.resolve(), due - this.#clock.Date.now())
345
+ : undefined;
346
+ await woken.promise;
347
+ if (retrying !== undefined)
348
+ this.#clock.clearTimeout(retrying);
349
+ continue;
350
+ }
351
+ const steps = connection.steps.filter((copy) => pending.has(copy.from.name));
352
+ pending.clear();
353
+ for (const copy of steps)
354
+ loading.add(copy.from.name);
355
+ // Like a schedule after a failed sync, watching goes on: each pass
356
+ // reports what did not load, and a retry or the source's next change
357
+ // loads it again. A stopping watch stops the pass it is running, as
358
+ // run({ signal }) does.
359
+ const pass = await this.#pass(connection, steps, signal, committed);
360
+ for (const { copy, failures } of pass.outcomes) {
361
+ const { name } = copy.from;
362
+ loading.delete(name);
363
+ if (failures.length === 0)
364
+ loaded.add(name);
365
+ else
366
+ loaded.delete(name);
367
+ // A success, a failure only the user can fix, or the last failure
368
+ // the limit allows ends the run of retries.
369
+ const inARow = (retries.get(name)?.failures ?? 0) + 1;
370
+ if (failures.some(({ failureType }) => failureType === 'system') &&
371
+ inARow < retryLimit)
372
+ retries.set(name, {
373
+ failures: inARow,
374
+ at: this.#clock.Date.now() + retryDelayMs(inARow),
375
+ });
376
+ else
377
+ retries.delete(name);
378
+ }
379
+ yield pass;
380
+ }
381
+ if (unrecorded !== undefined)
382
+ throw unrecorded.error;
383
+ if (failure !== undefined) {
384
+ await this.#recordFailure(connection, failure.error, [
385
+ ...streams.keys(),
386
+ ]);
387
+ stopped.push(connectionError(connection, failure.error));
388
+ }
389
+ }
390
+ catch (error) {
391
+ // No history recorded this error, so no reader would see it: every
392
+ // connection stops at once, rather than once each next changes.
393
+ everyone.abort();
394
+ throw error;
395
+ }
396
+ finally {
397
+ controller.abort();
398
+ const graced = new AbortController();
399
+ await Promise.race([
400
+ receive,
401
+ delay(stopGraceMs, {
402
+ clock: this.#clock,
403
+ signal: graced.signal,
404
+ ref: false,
405
+ }).catch(() => undefined),
406
+ ]);
407
+ graced.abort();
408
+ }
409
+ }
410
+ // One read per pass, never held between watch passes: a long read can
411
+ // block the upstream's own maintenance, such as SQLite WAL checkpoints.
412
+ // committed is told each time a copy of the pass made rows visible.
413
+ async #pass(connection, steps, signal, committed) {
414
+ const { source } = connection;
415
+ // Asked before the read, so a store an app stopped writing is current
416
+ // only as of then.
417
+ const asked = await Promise.all(steps.map(async (copy) => {
418
+ try {
419
+ return { copy, upstream: await source.currentAsOf(copy.from) };
420
+ }
421
+ catch (error) {
422
+ return { copy, unknown: { error } };
423
+ }
424
+ }));
425
+ const record = await this.history.begin(connection, steps.map((copy) => ({ copy, coverage: source.coverage(copy.from) })), this.#now());
426
+ // The pass runs in the stores that subscribers bind to its start, so a
427
+ // subscriber's async context follows the pass's work, as a span's
428
+ // context follows it (Node diagnostics_channel, Channel.runStores).
429
+ return passChannels.start.runStores(new PassStarted(connection), async () => {
430
+ const read = await replicate(source, connection.destination, connection.checkpoints, asked.flatMap((answer) => 'upstream' in answer ? [answer.copy] : []), connection, committed, signal, this.#clock);
431
+ // A copy whose source could not tell how current its upstream is was not
432
+ // read: what it would load could not be dated.
433
+ const outcomes = [
434
+ ...read,
435
+ ...asked.flatMap((answer) => 'unknown' in answer
436
+ ? [
437
+ {
438
+ copy: answer.copy,
439
+ count: 0,
440
+ deleted: 0,
441
+ failures: [
442
+ {
443
+ partition: null,
444
+ error: answer.unknown.error,
445
+ failureType: source.failureType(answer.unknown.error),
446
+ },
447
+ ],
448
+ cancelled: false,
449
+ },
450
+ ]
451
+ : []),
452
+ ];
453
+ await record.finish(outcomes, new Map(asked.flatMap((answer) => 'upstream' in answer && answer.upstream !== null
454
+ ? [[answer.copy.from.name, answer.upstream]]
455
+ : [])), this.#now());
456
+ passChannels.finish.publish(new PassFinished(connection, outcomes));
457
+ return Object.freeze({ connection, outcomes: Object.freeze(outcomes) });
458
+ });
459
+ }
460
+ // Records a failure as a failed attempt of the streams that it stopped: why
461
+ // a connection stopped, or why the change signal of one stream failed. When
462
+ // the history cannot, the SuppressedError holds both errors, as a failed
463
+ // dispose does.
464
+ async #recordFailure(connection, failure, stopped) {
465
+ const failureType = connection.source.failureType(failure);
466
+ // The attempt declares each stream once, as one read covers it once: a
467
+ // connection that copies a stream twice fails its validation for that,
468
+ // and the history still records why.
469
+ const streams = connection.steps.filter((copy, index, steps) => stopped.includes(copy.from.name) &&
470
+ steps.findIndex(({ from }) => from.name === copy.from.name) === index);
471
+ try {
472
+ const record = await this.history.begin(connection, streams.map((copy) => ({
473
+ copy,
474
+ coverage: connection.source.coverage(copy.from),
475
+ })), this.#now());
476
+ await record.fail(failure, failureType, this.#now());
477
+ }
478
+ catch (error) {
479
+ throw new SuppressedError(error, failure, `Connection ${connection.name} stopped (${errorMessage(failure)}), and its sync history could not record why: ${errorMessage(error)}`);
480
+ }
481
+ passChannels.failure.publish(new PassFailed(connection, failure, failureType));
482
+ }
483
+ // Every declaration is checked before any connection reads, so a wiring
484
+ // mistake stops the whole pipeline; the connection it belongs to records it,
485
+ // in a history installed first.
486
+ async #declare() {
487
+ this.#assertDistinct();
488
+ for (const connection of this.connections)
489
+ this.history.validate(connection);
490
+ await this.history.install(this.connections, this.#now());
491
+ for (const connection of this.connections)
492
+ try {
493
+ connection.validate();
494
+ }
495
+ catch (error) {
496
+ await this.#recordFailure(connection, error, connection.steps.map(({ from }) => from.name));
497
+ throw error;
498
+ }
499
+ for (const dataset of this.derived)
500
+ dataset.validate(this.connections);
501
+ }
502
+ // The time of an event the history records, on the pipeline's clock.
503
+ #now() {
504
+ return new Date(this.#clock.Date.now());
505
+ }
506
+ #assertDistinct() {
507
+ const names = this.connections.map(({ name }) => name);
508
+ if (new Set(names).size !== names.length)
509
+ throw new TypeError('Pipeline connection names must be distinct');
510
+ const ids = this.connections
511
+ .flatMap(({ steps }) => steps.map(({ id }) => id))
512
+ .filter((id) => id !== undefined);
513
+ if (new Set(ids).size !== ids.length)
514
+ throw new TypeError('Pipeline copy IDs must be distinct');
515
+ const derived = this.derived.map(({ name }) => name);
516
+ if (new Set(derived).size !== derived.length)
517
+ throw new TypeError('Pipeline derived dataset names must be distinct');
518
+ }
519
+ }
520
+ function connectionError(connection, cause) {
521
+ return new Error(`Connection ${connection.name}: ${errorMessage(cause)}`, {
522
+ cause,
523
+ });
524
+ }
525
+ // The streams that checks found unchanged, each current as of its check or as
526
+ // of earlier when its source says its upstream was current only then. A
527
+ // stream whose source cannot tell is left out: the check does not vouch for
528
+ // it.
529
+ async function checked(source, checks) {
530
+ const answers = await Promise.allSettled(checks.map(({ stream }) => source.currentAsOf(stream)));
531
+ return checks.flatMap(({ stream, at }, index) => {
532
+ const answer = answers[index];
533
+ if (answer?.status !== 'fulfilled')
534
+ return [];
535
+ const upstream = answer.value;
536
+ return [
537
+ {
538
+ stream: stream.name,
539
+ at: upstream !== null && upstream < at ? upstream : at,
540
+ },
541
+ ];
542
+ });
543
+ }
544
+ function isDisposable(value) {
545
+ return Symbol.dispose in value || Symbol.asyncDispose in value;
546
+ }
@@ -0,0 +1,6 @@
1
+ export type ReaderRelation = {
2
+ readonly name: string;
3
+ readonly description: string;
4
+ readonly columns: Readonly<Record<string, string>>;
5
+ };
6
+ export declare const readerCatalog: ReaderRelation;
@@ -0,0 +1,11 @@
1
+ // Where readers discover every documented relation and column.
2
+ export const readerCatalog = Object.freeze({
3
+ name: 'catalog',
4
+ description: 'One row per readable view, table and column, explaining meaning and use. Discover sync_status and extraction_coverage before judging freshness or completeness. Raw tables are not listed.',
5
+ columns: Object.freeze({
6
+ kind: 'view, table or column.',
7
+ name: 'Relation name, or relation.column.',
8
+ data_type: 'Column type; NULL for relations.',
9
+ description: 'Meaning, limitations and usage guidance; does not authorize refresh or other writes.',
10
+ }),
11
+ });
@@ -0,0 +1,58 @@
1
+ export type ScalarSchema = {
2
+ readonly description?: string;
3
+ readonly format?: 'date-time' | 'date' | 'date-time-local' | 'time-local' | 'int64' | 'decimal';
4
+ readonly precision?: number;
5
+ readonly scale?: number;
6
+ readonly contentEncoding?: 'base64';
7
+ readonly minimum?: number;
8
+ readonly maximum?: number;
9
+ readonly minLength?: number;
10
+ readonly enum?: readonly (string | number)[];
11
+ };
12
+ export type ScalarItemSchema = ScalarSchema & {
13
+ readonly type: Exclude<ScalarType, 'null'>;
14
+ };
15
+ export type ObjectFieldSchema = ScalarSchema & {
16
+ readonly type: ScalarType | readonly ScalarType[];
17
+ };
18
+ export type ObjectItemSchema = {
19
+ readonly type: 'object';
20
+ readonly description?: string;
21
+ readonly properties: Readonly<Record<string, ObjectFieldSchema>>;
22
+ readonly required: readonly string[];
23
+ };
24
+ export type ItemSchema = ScalarItemSchema | ObjectItemSchema;
25
+ export type FieldSchema = ScalarSchema & {
26
+ readonly type: FieldType | readonly FieldType[];
27
+ readonly items?: ItemSchema;
28
+ };
29
+ type FieldType = ScalarType | 'array';
30
+ type ScalarType = 'string' | 'integer' | 'number' | 'boolean' | 'null';
31
+ type ScalarValue<T> = T extends 'string' ? string : T extends 'integer' | 'number' ? number : T extends 'boolean' ? boolean : T extends 'null' ? null : never;
32
+ type Types<F extends {
33
+ readonly type: unknown;
34
+ }> = F['type'] extends readonly (infer T)[] ? T : F['type'];
35
+ type ItemValue<I extends ItemSchema> = I extends ObjectItemSchema ? {
36
+ -readonly [K in keyof I['properties']]: ScalarValue<Types<I['properties'][K]>>;
37
+ } : ScalarValue<I['type']>;
38
+ type FieldValue<F extends FieldSchema> = 'array' extends Types<F> ? F['items'] extends ItemSchema ? ItemValue<F['items']>[] | ScalarValue<Exclude<Types<F>, 'array'>> : never : ScalarValue<Types<F>>;
39
+ export type Properties = Readonly<Record<string, FieldSchema>>;
40
+ export type StreamSchema = {
41
+ readonly type: 'object';
42
+ readonly description?: string;
43
+ readonly properties?: Properties;
44
+ readonly required?: readonly string[];
45
+ };
46
+ export type SchemaRecord<P extends Properties> = string extends keyof P ? Record<string, unknown> : {
47
+ -readonly [K in keyof P]: FieldValue<P[K]>;
48
+ };
49
+ export type RecordDraft<P extends Properties> = {
50
+ readonly [K in keyof P]: unknown;
51
+ };
52
+ export declare function validateRecords<P extends Properties>(stream: {
53
+ readonly name: string;
54
+ readonly jsonSchema: {
55
+ readonly properties?: P;
56
+ };
57
+ }, records: unknown, source: string): SchemaRecord<P>[];
58
+ export {};