@lde/pipeline 0.31.5 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A framework for transforming large RDF datasets, primarily using [SPARQL](https://www.w3.org/TR/sparql11-query/) queries with TypeScript for the parts that are hard to express in SPARQL alone.
4
4
 
5
5
  - **SPARQL-native.** Data transformations are plain SPARQL query files — portable, transparent, testable and version-controlled.
6
- - **Composable.** Executors are an interface: wrap a SPARQL executor with custom TypeScript to handle edge cases like date parsing or string normalisation (see [Executor](#executor)).
6
+ - **Composable.** Executors are an interface: wrap a SPARQL reader with custom TypeScript to handle edge cases like date parsing or string normalisation (see [Reader](#reader)).
7
7
  - **Extensible.** A plugin system lets packages like [@lde/pipeline-void](../pipeline-void) (or your own plugins) hook into the pipeline lifecycle.
8
8
 
9
9
  ## Components
@@ -50,7 +50,7 @@ const resolver = new ImportResolver(new SparqlDistributionResolver(), {
50
50
 
51
51
  ### Stage
52
52
 
53
- A stage groups an item selector, one or more executors, and configuration:
53
+ A stage groups an item selector, one or more readers, and configuration:
54
54
 
55
55
  ```typescript
56
56
  new Stage({
@@ -58,7 +58,7 @@ new Stage({
58
58
  itemSelector: new SparqlItemSelector({
59
59
  query: 'SELECT DISTINCT ?class WHERE { ?s a ?class }',
60
60
  }),
61
- executors: executor,
61
+ readers: reader,
62
62
  batchSize: 100,
63
63
  maxConcurrency: 5,
64
64
  });
@@ -66,9 +66,9 @@ new Stage({
66
66
 
67
67
  #### Batch size
68
68
 
69
- `batchSize` (default: 10) controls how many variable bindings are passed to each executor call as a `VALUES` clause. It also sets the page size for the item selector's SPARQL requests, so that each paginated request fills exactly one executor batch.
69
+ `batchSize` (default: 10) controls how many variable bindings are passed to each reader call as a `VALUES` clause. It also sets the page size for the item selector's SPARQL requests, so that each paginated request fills exactly one reader batch.
70
70
 
71
- Some SPARQL endpoints enforce different result limits for SELECT and CONSTRUCT queries. Since the selector uses SELECT and the executor uses CONSTRUCT, a `LIMIT` clause in the selector query overrides `batchSize` as the page size. Use this when the endpoint caps SELECT results below your desired batch size:
71
+ Some SPARQL endpoints enforce different result limits for SELECT and CONSTRUCT queries. Since the selector uses SELECT and the reader uses CONSTRUCT, a `LIMIT` clause in the selector query overrides `batchSize` as the page size. Use this when the endpoint caps SELECT results below your desired batch size:
72
72
 
73
73
  ```typescript
74
74
  // Endpoint caps SELECT results at 500, but each CONSTRUCT can handle 1000 bindings.
@@ -77,14 +77,14 @@ new Stage({
77
77
  itemSelector: new SparqlItemSelector({
78
78
  query: 'SELECT DISTINCT ?class WHERE { ?s a ?class } LIMIT 500',
79
79
  }),
80
- executors: executor,
80
+ readers: reader,
81
81
  batchSize: 1000, // Two SELECT pages fill one CONSTRUCT batch.
82
82
  });
83
83
  ```
84
84
 
85
85
  #### Concurrency
86
86
 
87
- `maxConcurrency` (default: 10) limits the total number of concurrent SPARQL queries. Within each batch, all executors run in parallel; the number of concurrent batches is automatically reduced to `⌊maxConcurrency / executorCount⌋` so the total query pressure stays within the limit. For example, with `maxConcurrency: 10` and two executors per stage, up to 5 batches run concurrently (10 SPARQL queries total).
87
+ `maxConcurrency` (default: 10) limits the total number of concurrent SPARQL queries. Within each batch, all readers run in parallel; the number of concurrent batches is automatically reduced to `⌊maxConcurrency / executorCount⌋` so the total query pressure stays within the limit. For example, with `maxConcurrency: 10` and two readers per stage, up to 5 batches run concurrently (10 SPARQL queries total).
88
88
 
89
89
  #### Expecting output
90
90
 
@@ -94,7 +94,7 @@ Set it for scalar aggregates such as `SELECT (COUNT(*) AS ?n)`, which always ret
94
94
 
95
95
  ### Item Selector
96
96
 
97
- Selects resources from the distribution and fans out executor calls per batch of results. Implements the `ItemSelector` interface:
97
+ Selects resources from the distribution and fans out reader calls per batch of results. Implements the `ItemSelector` interface:
98
98
 
99
99
  ```typescript
100
100
  interface ItemSelector {
@@ -143,12 +143,12 @@ const itemSelector: ItemSelector = {
143
143
  };
144
144
  ```
145
145
 
146
- ### Executor
146
+ ### Reader
147
147
 
148
- Generates RDF triples. The built-in `SparqlConstructExecutor` runs a SPARQL CONSTRUCT query with template substitution and variable bindings:
148
+ Generates RDF triples. The built-in `SparqlConstructReader` runs a SPARQL CONSTRUCT query with template substitution and variable bindings:
149
149
 
150
150
  ```typescript
151
- const executor = new SparqlConstructExecutor({
151
+ const reader = new SparqlConstructReader({
152
152
  query: 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }',
153
153
  });
154
154
  ```
@@ -156,7 +156,7 @@ const executor = new SparqlConstructExecutor({
156
156
  When querying endpoints that return line-oriented formats like N-Triples (e.g. QLever), enable `lineBuffer` to work around an [N3.js chunk-splitting bug](https://github.com/rdfjs/N3.js/issues/578) that causes intermittent parse errors on large responses:
157
157
 
158
158
  ```typescript
159
- const executor = new SparqlConstructExecutor({
159
+ const reader = new SparqlConstructReader({
160
160
  query: 'CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }',
161
161
  lineBuffer: true,
162
162
  });
@@ -165,30 +165,30 @@ const executor = new SparqlConstructExecutor({
165
165
  SPARQL CONSTRUCT queries can produce duplicate triples — for example, constant triples (like `?dataset a edm:ProvidedCHO`) are emitted for every solution row. Enable `deduplicate` to remove duplicates inline on the stream using a string-based identity set (inspired by [Comunica's `distinctConstruct`](https://comunica.dev/docs/query/advanced/context/#14--distinct-construct)):
166
166
 
167
167
  ```typescript
168
- const executor = new SparqlConstructExecutor({
168
+ const reader = new SparqlConstructReader({
169
169
  query: 'CONSTRUCT { ?s a edm:ProvidedCHO . ?s ?p ?o } WHERE { ?s ?p ?o }',
170
170
  deduplicate: true,
171
171
  });
172
172
  ```
173
173
 
174
- The dedup set is scoped to each `execute()` call, so memory stays bounded to the number of unique quads per batch. A standalone `deduplicateQuads()` function is also exported for use outside the executor.
174
+ The dedup set is scoped to each `read()` call, so memory stays bounded to the number of unique quads per batch. A standalone `deduplicateQuads()` function is also exported for use outside the reader.
175
175
 
176
176
  ### Extending a stage with a quad transform
177
177
 
178
- Some logic is hard to express in pure SPARQL — cleaning up messy date notations, converting locale-specific dates to ISO 8601, or sampling an executor’s output and firing follow-up queries. Rather than subclass `Executor`, attach a `QuadTransform` to it as data: a plain function `(quads, context) => quads` that post-processes one executor’s output before the stage merges it with its siblings. This is extension point 1 of [ADR 2](../../docs/decisions/0002-unify-pipeline-extension-on-quad-transforms.md).
178
+ Some logic is hard to express in pure SPARQL — cleaning up messy date notations, converting locale-specific dates to ISO 8601, or sampling a reader’s output and firing follow-up queries. Rather than subclass `Reader`, attach a `QuadTransform` to it as data: a plain function `(quads, context) => quads` that post-processes one reader’s output before the stage merges it with its siblings. This is extension point 1 of [ADR 2](../../docs/decisions/0002-unify-pipeline-extension-on-quad-transforms.md).
179
179
 
180
- A transform receives an `ExecutorContext` — the `dataset`, the `distribution` (so it can fire its own SPARQL queries), and the `stage` name. It runs once per executor call, so **write it to accept being called more than once**: a global stage calls it once over the executor’s complete output, but a per-class stage with batching enabled calls it once per batch (one class at `batchSize: 1`). Accumulate within an invocation, not across invocations — or keep the transform per-quad, where the number of calls makes no difference.
180
+ A transform receives an `ReaderContext` — the `dataset`, the `distribution` (so it can fire its own SPARQL queries), and the `stage` name. It runs once per reader call, so **write it to accept being called more than once**: a global stage calls it once over the reader’s complete output, but a per-class stage with batching enabled calls it once per batch (one class at `batchSize: 1`). Accumulate within an invocation, not across invocations — or keep the transform per-quad, where the number of calls makes no difference.
181
181
 
182
182
  ```typescript
183
183
  import { DataFactory } from 'n3';
184
184
  import {
185
185
  Stage,
186
- SparqlConstructExecutor,
186
+ SparqlConstructReader,
187
187
  type QuadTransform,
188
- type ExecutorContext,
188
+ type ReaderContext,
189
189
  } from '@lde/pipeline';
190
190
 
191
- const cleanDates: QuadTransform<ExecutorContext> = async function* (quads) {
191
+ const cleanDates: QuadTransform<ReaderContext> = async function* (quads) {
192
192
  for await (const quad of quads) {
193
193
  if (quad.object.termType === 'Literal' && isMessyDate(quad.object)) {
194
194
  yield DataFactory.quad(
@@ -207,8 +207,8 @@ const cleanDates: QuadTransform<ExecutorContext> = async function* (quads) {
207
207
 
208
208
  new Stage({
209
209
  name: 'dates',
210
- executors: {
211
- executor: await SparqlConstructExecutor.fromFile('dates.rq'),
210
+ readers: {
211
+ reader: await SparqlConstructReader.fromFile('dates.rq'),
212
212
  transform: cleanDates,
213
213
  },
214
214
  });
@@ -252,11 +252,11 @@ Transitions are forwarded to the `ProgressReporter` via `timeoutTightened` / `ti
252
252
 
253
253
  Implement `TimeoutPolicy` directly for custom strategies (closing over shared state in the factory if you want it to span datasets).
254
254
 
255
- Timeouts live at the pipeline level — neither `SparqlConstructExecutor` nor `SparqlItemSelector` accept their own `timeout` option. Per-endpoint state belongs in the adaptive policy, and per-stage budgets aren’t supported. Reusable stage facades (`@lde/pipeline-void`, `@lde/pipeline-shacl-sampler`) follow the same convention.
255
+ Timeouts live at the pipeline level — neither `SparqlConstructReader` nor `SparqlItemSelector` accept their own `timeout` option. Per-endpoint state belongs in the adaptive policy, and per-stage budgets aren’t supported. Reusable stage facades (`@lde/pipeline-void`, `@lde/pipeline-shacl-sampler`) follow the same convention.
256
256
 
257
257
  ### Validation
258
258
 
259
- Stages can optionally validate their output quads against a `Validator`. Validation operates on the **combined output of all executors per batch**, not on individual quads or per-executor output. A batch produces a complete result set — a self-contained cluster of linked resources — that can be meaningfully matched against SHACL shapes. Even with a single executor, each batch is a complete unit; with multiple executors, shapes that reference triples from different executors are validated correctly.
259
+ Stages can optionally validate their output quads against a `Validator`. Validation operates on the **combined output of all readers per batch**, not on individual quads or per-reader output. A batch produces a complete result set — a self-contained cluster of linked resources — that can be meaningfully matched against SHACL shapes. Even with a single reader, each batch is a complete unit; with multiple readers, shapes that reference triples from different readers are validated correctly.
260
260
 
261
261
  Validating individual quads would be meaningless, since a single quad carries no structural context for shape matching. Validating the full pipeline output would also be problematic: because the pipeline streams results in batches, it doesn’t know where resource cluster boundaries fall. Batching the output could split a valid cluster across two batches, causing partial resources to fail validation even though the complete cluster is valid.
262
262
 
@@ -268,7 +268,7 @@ import { FileWriter } from '@lde/pipeline';
268
268
 
269
269
  new Stage({
270
270
  name: 'transform',
271
- executors: await SparqlConstructExecutor.fromFile('transform.rq'),
271
+ readers: await SparqlConstructReader.fromFile('transform.rq'),
272
272
  validation: {
273
273
  validator: new ShaclValidator({
274
274
  shapesFile: './shapes.ttl',
@@ -295,10 +295,26 @@ After all stages for a dataset have run, the pipeline calls `validator.report(da
295
295
 
296
296
  ### Writer
297
297
 
298
- Writes generated quads to a destination:
298
+ Writes generated quads to a destination. A `Writer` is transactional: each pipeline run opens one run on it (`openRun(context)`), writes every dataset through the resulting `RunWriter`, and ends with exactly one `commit()` (on success) or `abort(error)` (on failure). The run lifecycle is the home of destination-level concerns such as atomic swaps, deletion sweeps and cross-pod locks; the pipeline drives `openRun → write* → commit/abort` uniformly and never branches on the writer’s update mode. After a dataset’s stages complete, its `flush(dataset, outcome)` says whether the dataset succeeded, so a writer can gate destructive finalization (e.g. an In-place stale-document sweep) on `'success'`.
299
299
 
300
- - `SparqlUpdateWriter` — writes to a SPARQL endpoint via UPDATE queries
301
- - `FileWriter` — writes to local files
300
+ - `SparqlUpdateWriter` — writes to a SPARQL endpoint via UPDATE queries; writes are visible as they land, so `commit`/`abort` are no-ops
301
+ - `FileWriter` — writes to local files; each file is streamed to a sibling temp file and atomically renamed on flush, `commit` finalizes any files still open, and `abort` discards temp output
302
+
303
+ The `RunContext` handed to `openRun` carries the run’s identity (`runId`, `startedAt`), the full selection (`selectedSources()`, complete by commit time – including datasets skipped as unchanged) and the pipeline’s provenance store, when configured.
304
+
305
+ A destination without run-level state implements the same contract with no-op `commit`/`abort` – `SparqlUpdateWriter` shows the pattern:
306
+
307
+ ```typescript
308
+ async openRun(): Promise<RunWriter> {
309
+ return {
310
+ write: async (dataset, quads) => {
311
+ // send quads somewhere
312
+ },
313
+ commit: () => Promise.resolve(),
314
+ abort: () => Promise.resolve(),
315
+ };
316
+ }
317
+ ```
302
318
 
303
319
  ### Reporter
304
320
 
@@ -425,7 +441,7 @@ new Pipeline({
425
441
  import {
426
442
  Pipeline,
427
443
  Stage,
428
- SparqlConstructExecutor,
444
+ SparqlConstructReader,
429
445
  SparqlItemSelector,
430
446
  SparqlUpdateWriter,
431
447
  ManualDatasetSelection,
@@ -439,7 +455,7 @@ const pipeline = new Pipeline({
439
455
  itemSelector: new SparqlItemSelector({
440
456
  query: 'SELECT DISTINCT ?class WHERE { ?s a ?class }',
441
457
  }),
442
- executors: new SparqlConstructExecutor({
458
+ readers: new SparqlConstructReader({
443
459
  query:
444
460
  'CONSTRUCT { ?class a <http://example.org/Class> } WHERE { ?s a ?class }',
445
461
  }),
@@ -27,7 +27,7 @@ export interface PipelinePlugin {
27
27
  * Transform the merged, post-stage quad stream before writing (extension
28
28
  * point 2: pipeline-wide, post-merge). The home of cross-cutting concerns
29
29
  * – provenance, namespace normalisation – that apply regardless of which
30
- * executor produced a quad.
30
+ * reader produced a quad.
31
31
  */
32
32
  beforeStageWrite?: QuadTransform<BeforeStageWriteContext>;
33
33
  }
@@ -97,7 +97,7 @@ export declare class Pipeline {
97
97
  private reportValidators;
98
98
  private collectStages;
99
99
  /**
100
- * The writer a stage's merged output is written through: the user writer
100
+ * The writer a stage's merged output is written through: the open run
101
101
  * wrapped with the plugins' {@link PipelinePlugin.beforeStageWrite}
102
102
  * transforms, carrying this `stage`'s identity so a transform can mint stable
103
103
  * per-`(dataset, stage)` IRIs rather than blank nodes.
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAgB,MAAM,cAAc,CAAC;AAGrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAEjD,OAAO,EACL,KAAK,oBAAoB,EAI1B,MAAM,4BAA4B,CAAC;AAKpC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAY7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,KAAK,EAEV,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;GAOG;AACH,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,aAAa,CAAC,uBAAuB,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,eAAe,CAAC;IACjC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,EAAE;QACT,mBAAmB,EAAE,mBAAmB,CAAC;QACzC,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,gBAAgB,GAAG,SAAS,gBAAgB,EAAE,CAAC;IAC1D;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,MAAM,aAAa,CAAC;CAC/B;AAsFD,qBAAa,QAAQ;IACnB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkB;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAU;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAyC;IAC3E,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAuB;IAC5D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAA8B;IACxD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsB;IACrD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAkB;IACnD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAS;gBAE9B,OAAO,EAAE,eAAe;IA+C9B,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBZ,cAAc;IA6M5B,+EAA+E;YACjE,aAAa;YAmBb,gBAAgB;IAW9B,OAAO,CAAE,aAAa;IAOtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAMnB;;;;;;;;OAQG;IACH,OAAO,CAAC,0BAA0B;IA4BlC;;;;;OAKG;YACW,SAAS;IA8BvB;;;OAGG;YACW,QAAQ;IA0CtB,2EAA2E;YAC7D,eAAe;YAqBf,QAAQ;YA+DP,SAAS;CAczB"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAgB,MAAM,cAAc,CAAC;AAGrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAKV,MAAM,EACP,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,KAAK,oBAAoB,EAI1B,MAAM,4BAA4B,CAAC;AAKpC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAY7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,KAAK,EAEV,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;GAOG;AACH,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,aAAa,CAAC,uBAAuB,CAAC,CAAC;CAC3D;AAED,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,eAAe,CAAC;IACjC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,EAAE;QACT,mBAAmB,EAAE,mBAAmB,CAAC;QACzC,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,gBAAgB,GAAG,SAAS,gBAAgB,EAAE,CAAC;IAC1D;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,MAAM,aAAa,CAAC;CAC/B;AAqID,qBAAa,QAAQ;IACnB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkB;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAU;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAyC;IAC3E,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAuB;IAC5D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAA8B;IACxD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsB;IACrD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAkB;IACnD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAS;gBAE9B,OAAO,EAAE,eAAe;IA+C9B,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;YAyCZ,cAAc;IAqN5B,+EAA+E;YACjE,aAAa;YAmBb,gBAAgB;IAW9B,OAAO,CAAE,aAAa;IAOtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAMnB;;;;;;;;OAQG;IACH,OAAO,CAAC,0BAA0B;IA4BlC;;;;;OAKG;YACW,SAAS;IAuCvB;;;OAGG;YACW,QAAQ;IA0CtB,2EAA2E;YAC7D,eAAe;YAqBf,QAAQ;YAuEP,SAAS;CAczB"}
package/dist/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createReadStream } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { StreamParser } from 'n3';
3
4
  import { FileWriter } from './writer/fileWriter.js';
4
5
  import { NoDistributionAvailable, ResolvedDistribution, } from './distribution/resolver.js';
@@ -8,7 +9,7 @@ import { shouldReprocess } from './provenance/reprocessDecision.js';
8
9
  import { NetworkError, SparqlProbeResult, } from '@lde/distribution-probe';
9
10
  import { ImportSuccessful } from '@lde/sparql-importer';
10
11
  import { importOutcomeToVerdict, probeResultToVerdict, } from '@lde/distribution-health';
11
- import { NotSupported } from './sparql/executor.js';
12
+ import { NotSupported } from './sparql/reader.js';
12
13
  import { combineReporters } from './combineReporters.js';
13
14
  import { ConstantTimeoutPolicy, } from './sparql/timeoutPolicy.js';
14
15
  /**
@@ -54,17 +55,57 @@ class FanOutWriter {
54
55
  constructor(writers) {
55
56
  this.writers = writers;
56
57
  }
58
+ async openRun(context) {
59
+ // Opening a writer's run can have side effects (a cross-pod lock, a fresh
60
+ // collection). If one writer opens but a sibling then fails, the pipeline
61
+ // never gets a RunWriter to abort, so roll the opened ones back here
62
+ // before rethrowing — otherwise their locks and collections leak.
63
+ const results = await Promise.allSettled(this.writers.map((writer) => writer.openRun(context)));
64
+ const opened = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
65
+ const failure = results.find((result) => result.status === 'rejected');
66
+ if (failure) {
67
+ await Promise.allSettled(opened.map((run) => run.abort(failure.reason)));
68
+ throw failure.reason;
69
+ }
70
+ return new FanOutRunWriter(opened);
71
+ }
72
+ }
73
+ /**
74
+ * Run independent branch operations concurrently, giving every branch its
75
+ * chance even when one fails; the first failure is rethrown afterwards.
76
+ */
77
+ async function settleBranches(tasks) {
78
+ const results = await Promise.allSettled(tasks);
79
+ const failed = results.find((result) => result.status === 'rejected');
80
+ if (failed)
81
+ throw failed.reason;
82
+ }
83
+ class FanOutRunWriter {
84
+ runs;
85
+ constructor(runs) {
86
+ this.runs = runs;
87
+ }
57
88
  async write(dataset, quads) {
58
- const branches = tee(quads, this.writers.length);
59
- await Promise.all(this.writers.map((writer, index) => writer.write(dataset, branches[index])));
89
+ const branches = tee(quads, this.runs.length);
90
+ await Promise.all(this.runs.map((run, index) => run.write(dataset, branches[index])));
60
91
  }
61
- async flush(dataset) {
62
- for (const w of this.writers)
63
- await w.flush?.(dataset);
92
+ async flush(dataset, outcome) {
93
+ for (const run of this.runs)
94
+ await run.flush?.(dataset, outcome);
64
95
  }
65
96
  async reset(dataset) {
66
- for (const w of this.writers)
67
- await w.reset?.(dataset);
97
+ for (const run of this.runs)
98
+ await run.reset?.(dataset);
99
+ }
100
+ async commit() {
101
+ // Destinations are independent, so their commits (alias swaps, sweeps –
102
+ // the slowest part of a run) need not queue behind each other.
103
+ await settleBranches(this.runs.map((run) => run.commit()));
104
+ }
105
+ async abort(error) {
106
+ // Abort every branch even when one abort fails: each destination must get
107
+ // its chance to clean up.
108
+ await settleBranches(this.runs.map((run) => run.abort(error)));
68
109
  }
69
110
  }
70
111
  class TransformWriter {
@@ -134,8 +175,28 @@ export class Pipeline {
134
175
  const selectStart = Date.now();
135
176
  const datasets = await this.datasetSelector.select();
136
177
  this.reporter?.datasetsSelected?.(datasets.total, Date.now() - selectStart);
137
- for await (const dataset of datasets) {
138
- await this.processDataset(dataset);
178
+ // The run transaction: one openRun → write* → commit/abort per pipeline
179
+ // run. `selectedSources` accumulates every selected dataset – including
180
+ // ones skipped as unchanged – so a writer's commit can sweep by registry
181
+ // membership.
182
+ const selectedSources = [];
183
+ const context = {
184
+ runId: randomUUID(),
185
+ startedAt: new Date().toISOString(),
186
+ selectedSources: () => selectedSources,
187
+ provenance: this.provenanceStore,
188
+ };
189
+ const runWriter = await this.writer.openRun(context);
190
+ try {
191
+ for await (const dataset of datasets) {
192
+ selectedSources.push(dataset.iri.toString());
193
+ await this.processDataset(dataset, runWriter, context);
194
+ }
195
+ await runWriter.commit();
196
+ }
197
+ catch (error) {
198
+ await runWriter.abort(error);
199
+ throw error;
139
200
  }
140
201
  const finalMemory = process.memoryUsage();
141
202
  this.reporter?.pipelineComplete?.({
@@ -144,7 +205,7 @@ export class Pipeline {
144
205
  heapUsedBytes: finalMemory.heapUsed,
145
206
  });
146
207
  }
147
- async processDataset(dataset) {
208
+ async processDataset(dataset, runWriter, context) {
148
209
  this.reporter?.datasetStart?.(dataset);
149
210
  // Probe phase: gather probe results and the source-to-be, without importing.
150
211
  let probed;
@@ -232,7 +293,7 @@ export class Pipeline {
232
293
  });
233
294
  let stageFailed = false;
234
295
  try {
235
- stageFailed = await this.runStages(dataset, resolved.distribution, timeout);
296
+ stageFailed = await this.runStages(dataset, resolved.distribution, timeout, runWriter, context);
236
297
  // Reactive fallback: an endpoint that passed probing but could not serve
237
298
  // the analysis stages is empirically incapable. Switch to the dataset’s
238
299
  // data dump and re-run all stages locally, discarding the
@@ -265,8 +326,8 @@ export class Pipeline {
265
326
  this.reportSelectedDistribution(dataset, fallback, fingerprint);
266
327
  // Discard the endpoint-sourced partial output before the re-run so
267
328
  // the dump-sourced stats replace it rather than appending to it.
268
- await this.writer.reset?.(dataset);
269
- stageFailed = await this.runStages(dataset, fallback.distribution, timeout);
329
+ await runWriter.reset?.(dataset);
330
+ stageFailed = await this.runStages(dataset, fallback.distribution, timeout, runWriter, context);
270
331
  }
271
332
  else if (fallback.importFailed) {
272
333
  // A failed dump import is a deep validity verdict on that dump –
@@ -284,7 +345,7 @@ export class Pipeline {
284
345
  await this.distributionResolver.cleanup?.();
285
346
  unsubscribe?.();
286
347
  }
287
- await this.writer.flush?.(dataset);
348
+ await runWriter.flush?.(dataset, stageFailed ? 'failed' : 'success');
288
349
  await this.reportValidators(dataset);
289
350
  // A dataset whose stages threw produced incomplete output; record it as
290
351
  // ‘failed’ rather than freezing a broken result under a ‘success’ record.
@@ -331,15 +392,15 @@ export class Pipeline {
331
392
  }
332
393
  }
333
394
  /**
334
- * The writer a stage's merged output is written through: the user writer
395
+ * The writer a stage's merged output is written through: the open run
335
396
  * wrapped with the plugins' {@link PipelinePlugin.beforeStageWrite}
336
397
  * transforms, carrying this `stage`'s identity so a transform can mint stable
337
398
  * per-`(dataset, stage)` IRIs rather than blank nodes.
338
399
  */
339
- stageWriter(stage) {
400
+ stageWriter(runWriter, stage) {
340
401
  return this.beforeStageWrite
341
- ? new TransformWriter(this.writer, this.beforeStageWrite, stage)
342
- : this.writer;
402
+ ? new TransformWriter(runWriter, this.beforeStageWrite, stage)
403
+ : runWriter;
343
404
  }
344
405
  /**
345
406
  * Report a resolved distribution as the dataset's selected source, plus its
@@ -362,15 +423,15 @@ export class Pipeline {
362
423
  * whether any stage hard-failed – the signal the reactive dump fallback
363
424
  * reacts to.
364
425
  */
365
- async runStages(dataset, distribution, timeout) {
426
+ async runStages(dataset, distribution, timeout, runWriter, context) {
366
427
  let stageFailed = false;
367
428
  for (const stage of this.stages) {
368
429
  try {
369
430
  if (stage.stages.length > 0) {
370
- await this.runChain(dataset, distribution, stage, timeout);
431
+ await this.runChain(dataset, distribution, stage, runWriter, context, timeout);
371
432
  }
372
433
  else {
373
- await this.runStage(dataset, distribution, stage, this.stageWriter(stage.name), timeout);
434
+ await this.runStage(dataset, distribution, stage, this.stageWriter(runWriter, stage.name), timeout);
374
435
  }
375
436
  }
376
437
  catch (error) {
@@ -384,7 +445,7 @@ export class Pipeline {
384
445
  * Run a stage with reporting and return whether it was supported.
385
446
  * Returns `true` if the stage produced results, `false` if NotSupported.
386
447
  */
387
- async runStage(dataset, distribution, stage, writer = this.writer, timeout) {
448
+ async runStage(dataset, distribution, stage, writer, timeout) {
388
449
  this.reporter?.stageStart?.(stage.name);
389
450
  const stageStart = Date.now();
390
451
  let itemsProcessed = 0;
@@ -421,34 +482,46 @@ export class Pipeline {
421
482
  throw new Error(`Stage '${stage.name}' returned NotSupported in chained mode`);
422
483
  }
423
484
  }
424
- async runChain(dataset, distribution, stage, timeout) {
485
+ async runChain(dataset, distribution, stage, runWriter, context, timeout) {
425
486
  const { stageOutputResolver, outputDir } = this.chaining;
426
487
  const outputFiles = [];
427
- try {
428
- // 1. Run parent stage → FileWriter.
429
- const parentWriter = new FileWriter({
430
- outputDir: `${outputDir}/${stage.name}`,
488
+ // Run one chained stage into its scratch FileWriter and flush it, so the
489
+ // output file exists on its final path before the resolver reads it. The
490
+ // scratch run is bracketed like any other: committed on success, aborted
491
+ // on failure so no half-written temp file is left behind.
492
+ const runScratchStage = async (chainedStage, stageDistribution) => {
493
+ const scratchWriter = new FileWriter({
494
+ outputDir: `${outputDir}/${chainedStage.name}`,
431
495
  format: 'n-triples',
432
496
  });
433
- await this.runChainedStage(dataset, distribution, stage, parentWriter, timeout);
434
- outputFiles.push(parentWriter.getOutputPath(dataset));
497
+ const scratchRun = await scratchWriter.openRun(context);
498
+ try {
499
+ await this.runChainedStage(dataset, stageDistribution, chainedStage, scratchRun, timeout);
500
+ await scratchRun.flush(dataset, 'success');
501
+ await scratchRun.commit();
502
+ }
503
+ catch (error) {
504
+ await scratchRun.abort(error);
505
+ throw error;
506
+ }
507
+ return scratchWriter.getOutputPath(dataset);
508
+ };
509
+ try {
510
+ // 1. Run parent stage → scratch FileWriter.
511
+ const parentOutput = await runScratchStage(stage, distribution);
512
+ outputFiles.push(parentOutput);
435
513
  // 2. Chain through children.
436
- let currentDistribution = await stageOutputResolver.resolve(parentWriter.getOutputPath(dataset));
514
+ let currentDistribution = await stageOutputResolver.resolve(parentOutput);
437
515
  for (let i = 0; i < stage.stages.length; i++) {
438
- const child = stage.stages[i];
439
- const childWriter = new FileWriter({
440
- outputDir: `${outputDir}/${child.name}`,
441
- format: 'n-triples',
442
- });
443
- await this.runChainedStage(dataset, currentDistribution, child, childWriter, timeout);
444
- outputFiles.push(childWriter.getOutputPath(dataset));
516
+ const childOutput = await runScratchStage(stage.stages[i], currentDistribution);
517
+ outputFiles.push(childOutput);
445
518
  if (i < stage.stages.length - 1) {
446
- currentDistribution = await stageOutputResolver.resolve(childWriter.getOutputPath(dataset));
519
+ currentDistribution = await stageOutputResolver.resolve(childOutput);
447
520
  }
448
521
  }
449
- // 3. Concatenate all output files → user writer, applying the plugins'
522
+ // 3. Concatenate all output files → the open run, applying the plugins'
450
523
  // beforeStageWrite transforms once for the chain under the parent stage.
451
- await this.stageWriter(stage.name).write(dataset, this.readFiles(outputFiles));
524
+ await this.stageWriter(runWriter, stage.name).write(dataset, this.readFiles(outputFiles));
452
525
  }
453
526
  finally {
454
527
  await stageOutputResolver.cleanup();
@@ -1 +1 @@
1
- {"version":3,"file":"fileLoadedSparqlProvenanceStore.d.ts","sourceRoot":"","sources":["../../src/provenance/fileLoadedSparqlProvenanceStore.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAI9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAgBlD,MAAM,WAAW,sCAAsC;IACrD,wEAAwE;IACxE,aAAa,EAAE,GAAG,CAAC;IACnB;;;OAGG;IACH,WAAW,EAAE,GAAG,CAAC;IACjB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,qBAAqB,CAAC;CACjC;AAED;;;;;;;;GAQG;AACH,qBAAa,+BAAgC,YAAW,eAAe;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAM;IACpC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAM;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;gBAEpC,OAAO,EAAE,sCAAsC;IAWrD,GAAG,CAAC,UAAU,EAAE,GAAG,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAoB5D,OAAO,CAAC,WAAW;IAmBb,GAAG,CAAC,UAAU,EAAE,GAAG,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;YAMpD,OAAO;CAsBvB"}
1
+ {"version":3,"file":"fileLoadedSparqlProvenanceStore.d.ts","sourceRoot":"","sources":["../../src/provenance/fileLoadedSparqlProvenanceStore.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAI9D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAgBlD,MAAM,WAAW,sCAAsC;IACrD,wEAAwE;IACxE,aAAa,EAAE,GAAG,CAAC;IACnB;;;OAGG;IACH,WAAW,EAAE,GAAG,CAAC;IACjB;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,qBAAqB,CAAC;CACjC;AAED;;;;;;;;GAQG;AACH,qBAAa,+BAAgC,YAAW,eAAe;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAM;IACpC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAM;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;gBAEpC,OAAO,EAAE,sCAAsC;IAWrD,GAAG,CAAC,UAAU,EAAE,GAAG,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAoB5D,OAAO,CAAC,WAAW;IAmBb,GAAG,CAAC,UAAU,EAAE,GAAG,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;YASpD,OAAO;CAsBvB"}
@@ -68,8 +68,11 @@ export class FileLoadedSparqlProvenanceStore {
68
68
  }
69
69
  async set(datasetUri, record) {
70
70
  const dataset = new Dataset({ iri: datasetUri, distributions: [] });
71
- await this.writer.write(dataset, this.toQuads(datasetUri, record));
72
- await this.writer.flush(dataset);
71
+ // Each record is complete in itself, so it gets its own mini run:
72
+ // write + flush materializes the record file atomically.
73
+ const run = await this.writer.openRun();
74
+ await run.write(dataset, this.toQuads(datasetUri, record));
75
+ await run.flush(dataset);
73
76
  }
74
77
  async *toQuads(datasetUri, record) {
75
78
  const subject = namedNode(datasetUri.toString());
@@ -1,4 +1,4 @@
1
- export { deduplicateQuads, SparqlConstructExecutor, LineBufferTransform, NotSupported, readQueryFile, type ExecuteOptions, type Executor, type SparqlConstructExecutorOptions, type VariableBindings, } from './executor.js';
1
+ export { deduplicateQuads, SparqlConstructReader, LineBufferTransform, NotSupported, readQueryFile, type ReadOptions, type Reader, type SparqlConstructReaderOptions, type VariableBindings, } from './reader.js';
2
2
  export { SparqlItemSelector, type SparqlItemSelectorOptions, } from './selector.js';
3
3
  export { injectValues } from './values.js';
4
4
  export { withDefaultGraph } from './graph.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sparql/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,8BAA8B,EACnC,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,kBAAkB,EAClB,KAAK,yBAAyB,GAC/B,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sparql/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,qBAAqB,EACrB,mBAAmB,EACnB,YAAY,EACZ,aAAa,EACb,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,KAAK,4BAA4B,EACjC,KAAK,gBAAgB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,kBAAkB,EAClB,KAAK,yBAAyB,GAC/B,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE9C,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,oBAAoB,CAAC"}
@@ -1,4 +1,4 @@
1
- export { deduplicateQuads, SparqlConstructExecutor, LineBufferTransform, NotSupported, readQueryFile, } from './executor.js';
1
+ export { deduplicateQuads, SparqlConstructReader, LineBufferTransform, NotSupported, readQueryFile, } from './reader.js';
2
2
  export { SparqlItemSelector, } from './selector.js';
3
3
  export { injectValues } from './values.js';
4
4
  export { withDefaultGraph } from './graph.js';