@lde/pipeline 0.34.0 → 0.34.2

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
@@ -298,7 +298,7 @@ After all stages for a dataset have run, the pipeline calls `validator.report(da
298
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
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
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. In `n-quads` format it skolemises blank nodes to dataset-scoped IRIs, because such files are typically `cat`-indexed into one served graph where document-scoped blank-node labels from different datasets would otherwise fuse (see `blankNodes`/`failOnBlankNodes` and ldelements/lde#474); Turtle/N-Triples output is left unchanged
302
302
 
303
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
304
 
@@ -16,6 +16,13 @@ import type { QuadTransform } from '../stage.js';
16
16
  * NO blank nodes. Mint stable (skolem) IRIs instead — see `skolemIri` in
17
17
  * `@lde/dataset`. These helpers make that invariant testable and enforceable.
18
18
  *
19
+ * `FileWriter` skolemises blank nodes automatically for `n-quads` output as a
20
+ * safety net, so a producer that slips a blank node through cannot fuse it into
21
+ * another dataset there. These helpers still matter for producers that must hold
22
+ * the invariant regardless of writer – e.g. output bound for a `SparqlUpdateWriter`
23
+ * or a `cat`-indexed Turtle sink, which do not skolemise – and for turning a slip
24
+ * into a loud failure rather than a silently rewritten IRI.
25
+ *
19
26
  * See ldelements/lde#474 and netwerk-digitaal-erfgoed/dataset-knowledge-graph#352.
20
27
  */
21
28
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"blankNodes.d.ts","sourceRoot":"","sources":["../../src/guard/blankNodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,EAAE,CAU1D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,CAU9D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,KAAK,aAAa,CAAC,OAAO,CAAC,CAelE"}
1
+ {"version":3,"file":"blankNodes.d.ts","sourceRoot":"","sources":["../../src/guard/blankNodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,MAAM,EAAE,CAU1D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,CAU9D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,KAAK,aAAa,CAAC,OAAO,CAAC,CAelE"}
@@ -14,6 +14,13 @@
14
14
  * NO blank nodes. Mint stable (skolem) IRIs instead — see `skolemIri` in
15
15
  * `@lde/dataset`. These helpers make that invariant testable and enforceable.
16
16
  *
17
+ * `FileWriter` skolemises blank nodes automatically for `n-quads` output as a
18
+ * safety net, so a producer that slips a blank node through cannot fuse it into
19
+ * another dataset there. These helpers still matter for producers that must hold
20
+ * the invariant regardless of writer – e.g. output bound for a `SparqlUpdateWriter`
21
+ * or a `cat`-indexed Turtle sink, which do not skolemise – and for turning a slip
22
+ * into a loud failure rather than a silently rewritten IRI.
23
+ *
17
24
  * See ldelements/lde#474 and netwerk-digitaal-erfgoed/dataset-knowledge-graph#352.
18
25
  */
19
26
  /**
@@ -1,4 +1,5 @@
1
1
  import { Dataset } from '@lde/dataset';
2
+ import type { Quad } from '@rdfjs/types';
2
3
  import type { DatasetSelector } from './selector.js';
3
4
  import { Stage } from './stage.js';
4
5
  import type { QuadTransform } from './stage.js';
@@ -23,7 +24,7 @@ export interface BeforeStageWriteContext {
23
24
  /**
24
25
  * Context handed to a {@link PipelinePlugin.beforeDatasetWrite} transform: the
25
26
  * `dataset` whose complete, cross-stage output is being written. Unlike
26
- * {@link BeforeStageWriteContext} there is no `stage` — the transform sees one
27
+ * {@link BeforeStageWriteContext} there is no `stage` – the transform sees one
27
28
  * dataset's quads from every stage as a single stream.
28
29
  */
29
30
  export interface BeforeDatasetWriteContext {
@@ -36,7 +37,7 @@ export interface PipelinePlugin {
36
37
  * Transform a single stage's merged output before writing (extension point
37
38
  * 2: per stage, post-merge). Runs once per stage per dataset, carrying the
38
39
  * stage identity so it can mint stable per-`(dataset, stage)` IRIs. The home
39
- * of stage-scoped concerns — provenance stamping, namespace rewriting of a
40
+ * of stage-scoped concerns – provenance stamping, namespace rewriting of a
40
41
  * stage's own quads.
41
42
  */
42
43
  beforeStageWrite?: QuadTransform<BeforeStageWriteContext>;
@@ -45,7 +46,7 @@ export interface PipelinePlugin {
45
46
  * dataset, cross-stage). Runs once over the concatenation of every stage's
46
47
  * output for one dataset, after all stages complete and before the write is
47
48
  * flushed. The home of concerns that need to reconcile quads *across* stages
48
- * — deduplication, cross-stage roll-ups, or merging partition nodes that
49
+ * – deduplication, cross-stage roll-ups, or merging partition nodes that
49
50
  * different stages contribute to.
50
51
  *
51
52
  * The transform is a streaming {@link QuadTransform}: the pipeline pipes each
@@ -55,11 +56,19 @@ export interface PipelinePlugin {
55
56
  */
56
57
  beforeDatasetWrite?: QuadTransform<BeforeDatasetWriteContext>;
57
58
  }
58
- export interface PipelineOptions {
59
+ export interface PipelineOptions<Out = Quad> {
59
60
  datasetSelector: DatasetSelector;
60
- stages: Stage[];
61
- writers: Writer | Writer[];
62
- plugins?: PipelinePlugin[];
61
+ stages: Stage<Out>[];
62
+ writers: Writer<Out> | Writer<Out>[];
63
+ /**
64
+ * Quad-level plugins ({@link PipelinePlugin}). Available only to a plain quad
65
+ * pipeline: a projecting pipeline's write-side data is already the projected
66
+ * item, and both plugin points are `QuadTransform`s with no quads to act on,
67
+ * so the type forbids them (`never`). Reader-attached quad transforms are
68
+ * unaffected – they run before the projection seam. See
69
+ * {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | ADR 13}.
70
+ */
71
+ plugins?: [Out] extends [Quad] ? PipelinePlugin[] : never;
63
72
  name?: string;
64
73
  distributionResolver?: DistributionResolver;
65
74
  chaining?: {
@@ -101,7 +110,7 @@ export interface PipelineOptions {
101
110
  */
102
111
  timeout?: () => TimeoutPolicy;
103
112
  }
104
- export declare class Pipeline {
113
+ export declare class Pipeline<Out = Quad> {
105
114
  private readonly name;
106
115
  private readonly datasetSelector;
107
116
  private readonly stages;
@@ -114,7 +123,7 @@ export declare class Pipeline {
114
123
  private readonly timeoutFactory;
115
124
  private readonly provenanceStore?;
116
125
  private readonly pipelineVersion?;
117
- constructor(options: PipelineOptions);
126
+ constructor(options: PipelineOptions<Out>);
118
127
  run(): Promise<void>;
119
128
  private processDataset;
120
129
  /** Persist the processing record for a dataset, when a store is configured. */
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"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"}
package/dist/pipeline.js CHANGED
@@ -60,7 +60,7 @@ class FanOutWriter {
60
60
  // Opening a writer's run can have side effects (a cross-pod lock, a fresh
61
61
  // collection). If one writer opens but a sibling then fails, the pipeline
62
62
  // never gets a RunWriter to abort, so roll the opened ones back here
63
- // before rethrowing — otherwise their locks and collections leak.
63
+ // before rethrowing – otherwise their locks and collections leak.
64
64
  const results = await Promise.allSettled(this.writers.map((writer) => writer.openRun(context)));
65
65
  const opened = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
66
66
  const failure = results.find((result) => result.status === 'rejected');
@@ -86,8 +86,8 @@ class FanOutRunWriter {
86
86
  constructor(runs) {
87
87
  this.runs = runs;
88
88
  }
89
- async write(dataset, quads) {
90
- const branches = tee(quads, this.runs.length);
89
+ async write(dataset, items) {
90
+ const branches = tee(items, this.runs.length);
91
91
  await Promise.all(this.runs.map((run, index) => run.write(dataset, branches[index])));
92
92
  }
93
93
  async flush(dataset, outcome) {
@@ -241,13 +241,17 @@ export class Pipeline {
241
241
  this.writer = Array.isArray(options.writers)
242
242
  ? new FanOutWriter(options.writers)
243
243
  : options.writers;
244
- const transforms = options.plugins
244
+ // Plugins are quad-typed and only reachable when `Out` is `Quad` (the
245
+ // `plugins` field is `never` otherwise), but inside the class body `Out` is
246
+ // opaque, so the conditional-typed field is read as a plain plugin array.
247
+ const plugins = options.plugins;
248
+ const transforms = plugins
245
249
  ?.map((p) => p.beforeStageWrite)
246
250
  .filter((t) => t !== undefined);
247
251
  this.beforeStageWrite = transforms?.length
248
252
  ? (quads, context) => transforms.reduce((q, fn) => fn(q, context), quads)
249
253
  : undefined;
250
- const datasetTransforms = options.plugins
254
+ const datasetTransforms = plugins
251
255
  ?.map((p) => p.beforeDatasetWrite)
252
256
  .filter((t) => t !== undefined);
253
257
  this.beforeDatasetWrite = datasetTransforms?.length
@@ -285,7 +289,10 @@ export class Pipeline {
285
289
  };
286
290
  const openedWriter = await this.writer.openRun(context);
287
291
  // Wrap the run writer so a whole-dataset transform sees every stage's
288
- // output for a dataset as one stream before it is written.
292
+ // output for a dataset as one stream before it is written. The transform is
293
+ // quad-typed and only present when `Out` is `Quad` (a projecting pipeline
294
+ // has no plugins), so narrowing the run writer to `RunWriter<Quad>` here is
295
+ // sound – tsc cannot see that invariant.
289
296
  const runWriter = this.beforeDatasetWrite
290
297
  ? new DatasetTransformWriter(openedWriter, this.beforeDatasetWrite)
291
298
  : openedWriter;
@@ -495,6 +502,8 @@ export class Pipeline {
495
502
  this.reporter?.datasetValidated?.(dataset, report);
496
503
  }
497
504
  }
505
+ // Walks every stage and sub-stage to collect validators; it reads only
506
+ // `validator`/`stages`, never output, so it is agnostic to the item type.
498
507
  *collectStages(stages) {
499
508
  for (const stage of stages) {
500
509
  yield stage;
@@ -509,6 +518,9 @@ export class Pipeline {
509
518
  * per-`(dataset, stage)` IRIs rather than blank nodes.
510
519
  */
511
520
  stageWriter(runWriter, stage) {
521
+ // `beforeStageWrite` is quad-typed and only present when `Out` is `Quad` (a
522
+ // projecting pipeline has no plugins), so the `TransformWriter` wrap is
523
+ // reached only in that case; tsc cannot see the invariant, hence the casts.
512
524
  return this.beforeStageWrite
513
525
  ? new TransformWriter(runWriter, this.beforeStageWrite, stage)
514
526
  : runWriter;
@@ -596,10 +608,12 @@ export class Pipeline {
596
608
  async runChain(dataset, distribution, stage, runWriter, context, timeout) {
597
609
  const { stageOutputResolver, outputDir } = this.chaining;
598
610
  const outputFiles = [];
599
- // Run one chained stage into its scratch FileWriter and flush it, so the
600
- // output file exists on its final path before the resolver reads it. The
601
- // scratch run is bracketed like any other: committed on success, aborted
602
- // on failure so no half-written temp file is left behind.
611
+ // Chaining is a quad-only concern: every stage in a chain writes N-Triples
612
+ // to a scratch FileWriter (a `Writer<Quad>`), and a chained parent cannot
613
+ // project (a stage with `stages` forbids `project`), so it emits quads. The
614
+ // requirement "sub-stages imply `Out` is `Quad`" is not expressible – `stages`
615
+ // and `chaining` are independent options – so it is a runtime contract, and
616
+ // the scratch machinery treats the parent stage as `Stage<Quad>`.
603
617
  const runScratchStage = async (chainedStage, stageDistribution) => {
604
618
  const scratchWriter = new FileWriter({
605
619
  outputDir: `${outputDir}/${chainedStage.name}`,
@@ -618,7 +632,7 @@ export class Pipeline {
618
632
  return scratchWriter.getOutputPath(dataset);
619
633
  };
620
634
  try {
621
- // 1. Run parent stage → scratch FileWriter.
635
+ // 1. Run parent stage → scratch FileWriter (quad-only, per above).
622
636
  const parentOutput = await runScratchStage(stage, distribution);
623
637
  outputFiles.push(parentOutput);
624
638
  // 2. Chain through children.
@@ -632,6 +646,8 @@ export class Pipeline {
632
646
  }
633
647
  // 3. Concatenate all output files → the open run, applying the plugins'
634
648
  // beforeStageWrite transforms once for the chain under the parent stage.
649
+ // The concatenation is quads (per the quad-only contract above), written
650
+ // to the `Out`-typed run writer – sound because chaining implies `Quad`.
635
651
  await this.stageWriter(runWriter, stage.name).write(dataset, this.readFiles(outputFiles));
636
652
  }
637
653
  finally {
package/dist/stage.d.ts CHANGED
@@ -26,6 +26,23 @@ export interface ReaderContext {
26
26
  distribution: Distribution;
27
27
  stage: string;
28
28
  }
29
+ /**
30
+ * The pipeline's one type-changing seam: turn a root-complete batch of quads
31
+ * into a stage's output items. The only extension point whose output is not
32
+ * `Quad` – a quad enrichment is a {@link QuadTransform} attached to a reader
33
+ * (extension point 1), which runs *before* this. See
34
+ * {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | ADR 13}.
35
+ */
36
+ export type BatchTransform<Out> = (quads: readonly Quad[], context: BatchContext) => Iterable<Out> | AsyncIterable<Out>;
37
+ /**
38
+ * Context handed to a {@link BatchTransform}: {@link ReaderContext} plus the
39
+ * batch's selected roots. `bindings` are the item-selector rows the readers were
40
+ * given as a `VALUES` block, so the batch is root-complete by construction – the
41
+ * projection frames exactly these roots rather than discovering them.
42
+ */
43
+ export interface BatchContext extends ReaderContext {
44
+ bindings: readonly VariableBindings[];
45
+ }
29
46
  /**
30
47
  * An {@link Reader} with zero or more {@link QuadTransform}s attached as data.
31
48
  *
@@ -45,10 +62,27 @@ export interface AttachedReader {
45
62
  }
46
63
  /** One or more readers, each optionally carrying attached transforms. */
47
64
  export type StageReaders = Reader | AttachedReader | (Reader | AttachedReader)[];
48
- export interface StageOptions {
65
+ export interface StageOptions<Out = Quad> {
49
66
  name: string;
50
67
  readers: StageReaders;
51
68
  itemSelector?: ItemSelector;
69
+ /**
70
+ * Turn each root-complete batch into this stage's output items – the one seam
71
+ * whose output type differs from `Quad` ({@link BatchTransform}). Requires
72
+ * {@link StageOptions.itemSelector} (a batch only exists under a selector) and
73
+ * forbids {@link StageOptions.stages} (a chained stage serializes to
74
+ * N-Triples, which a projected item cannot). Omit for a plain quad stage.
75
+ */
76
+ project?: BatchTransform<Out>;
77
+ /**
78
+ * Capacity of the bounded queue funnelling this stage's concurrent batches
79
+ * into the single write. The queue applies backpressure at this many items,
80
+ * so it bounds memory – set it where the cost per item is large (a projected
81
+ * document is far heavier than a quad). Only meaningful with an item selector.
82
+ *
83
+ * @default 128
84
+ */
85
+ queueCapacity?: number;
52
86
  /**
53
87
  * Maximum number of bindings per reader call.
54
88
  *
@@ -74,7 +108,7 @@ export interface StageOptions {
74
108
  * Treat a supported stage that produces no quads as a hard failure (throws),
75
109
  * rather than a legitimately empty result.
76
110
  *
77
- * Set this for stages whose query must yield output — typically a scalar
111
+ * Set this for stages whose query must yield output – typically a scalar
78
112
  * aggregate such as `SELECT (COUNT(*) AS ?n)`, which always returns exactly
79
113
  * one row, so zero quads can only mean the endpoint truncated or aborted the
80
114
  * response (e.g. a timeout surfaced as an empty `HTTP 200`). The resulting
@@ -109,7 +143,7 @@ export interface SelectOptions {
109
143
  /** Per-call timeout policy. */
110
144
  timeout?: TimeoutPolicy;
111
145
  }
112
- export declare class Stage {
146
+ export declare class Stage<Out = Quad> {
113
147
  readonly name: string;
114
148
  readonly stages: readonly Stage[];
115
149
  /** Whether an empty result is treated as a hard failure. @see {@link StageOptions.expectsOutput} */
@@ -119,13 +153,15 @@ export declare class Stage {
119
153
  private readonly batchSize;
120
154
  private readonly maxConcurrency;
121
155
  private readonly validation?;
122
- constructor(options: StageOptions);
156
+ private readonly project?;
157
+ private readonly queueCapacity?;
158
+ constructor(options: StageOptions<Out>);
123
159
  /** The validator for this stage, if configured. */
124
160
  get validator(): Validator | undefined;
125
- run(dataset: Dataset, distribution: Distribution, writer: DatasetWriter, options?: RunOptions): Promise<NotSupported | void>;
161
+ run(dataset: Dataset, distribution: Distribution, writer: DatasetWriter<Out>, options?: RunOptions): Promise<NotSupported | void>;
126
162
  /**
127
163
  * Throw when {@link StageOptions.expectsOutput} is set but the stage produced
128
- * no quads — a supported-but-empty result that signals a truncated or aborted
164
+ * no quads – a supported-but-empty result that signals a truncated or aborted
129
165
  * endpoint response rather than a legitimately empty one.
130
166
  */
131
167
  private assertProduced;
@@ -1 +1 @@
1
- {"version":3,"file":"stage.d.ts","sourceRoot":"","sources":["../src/stage.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"stage.d.ts","sourceRoot":"","sources":["../src/stage.ts"],"names":[],"mappings":"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"}
package/dist/stage.js CHANGED
@@ -11,7 +11,15 @@ export class Stage {
11
11
  batchSize;
12
12
  maxConcurrency;
13
13
  validation;
14
+ project;
15
+ queueCapacity;
14
16
  constructor(options) {
17
+ if (options.project && !options.itemSelector) {
18
+ throw new Error(`Stage '${options.name}': 'project' requires an 'itemSelector' – without one there is no batch to project, only the readers' whole output.`);
19
+ }
20
+ if (options.project && (options.stages?.length ?? 0) > 0) {
21
+ throw new Error(`Stage '${options.name}': 'project' cannot combine with chained 'stages' – a chained stage serializes to N-Triples, which a projected item cannot.`);
22
+ }
15
23
  this.name = options.name;
16
24
  this.stages = options.stages ?? [];
17
25
  this.readers = normalizeReaders(options.readers);
@@ -20,6 +28,8 @@ export class Stage {
20
28
  this.maxConcurrency = options.maxConcurrency ?? 10;
21
29
  this.validation = options.validation;
22
30
  this.expectsOutput = options.expectsOutput ?? false;
31
+ this.project = options.project;
32
+ this.queueCapacity = options.queueCapacity;
23
33
  }
24
34
  /** The validator for this stage, if configured. */
25
35
  get validator() {
@@ -36,6 +46,11 @@ export class Stage {
36
46
  if (streams instanceof NotSupported) {
37
47
  return streams;
38
48
  }
49
+ // The non-selector path has no batch, so `project` is forbidden here (the
50
+ // constructor rejects `project` without an `itemSelector`). It therefore
51
+ // only ever writes quads, and `Out` is `Quad` at runtime – tsc cannot see
52
+ // that invariant, so the writer is narrowed once.
53
+ const quadWriter = writer;
39
54
  // Quads the readers produced (before any validation filtering); used to
40
55
  // enforce `expectsOutput` below.
41
56
  let produced = 0;
@@ -50,7 +65,7 @@ export class Stage {
50
65
  const onInvalid = this.validation.onInvalid ?? 'write';
51
66
  if (onInvalid === 'write') {
52
67
  await Promise.all([
53
- writer.write(dataset, (async function* () {
68
+ quadWriter.write(dataset, (async function* () {
54
69
  yield* buffer;
55
70
  })()),
56
71
  this.validation.validator.validate(buffer, dataset),
@@ -59,7 +74,7 @@ export class Stage {
59
74
  else {
60
75
  const accepted = await this.validateBuffer(buffer, dataset);
61
76
  if (accepted.length > 0) {
62
- await writer.write(dataset, (async function* () {
77
+ await quadWriter.write(dataset, (async function* () {
63
78
  yield* accepted;
64
79
  })());
65
80
  }
@@ -68,18 +83,18 @@ export class Stage {
68
83
  else if (this.expectsOutput) {
69
84
  // Only thread the per-quad counter through when the count is actually
70
85
  // needed; the default path stays a plain streaming write with no overhead.
71
- await writer.write(dataset, countQuads(mergeStreams(streams), (count) => {
86
+ await quadWriter.write(dataset, countQuads(mergeStreams(streams), (count) => {
72
87
  produced = count;
73
88
  }));
74
89
  }
75
90
  else {
76
- await writer.write(dataset, mergeStreams(streams));
91
+ await quadWriter.write(dataset, mergeStreams(streams));
77
92
  }
78
93
  this.assertProduced(produced);
79
94
  }
80
95
  /**
81
96
  * Throw when {@link StageOptions.expectsOutput} is set but the stage produced
82
- * no quads — a supported-but-empty result that signals a truncated or aborted
97
+ * no quads – a supported-but-empty result that signals a truncated or aborted
83
98
  * endpoint response rather than a legitimately empty one.
84
99
  */
85
100
  assertProduced(produced) {
@@ -107,8 +122,11 @@ export class Stage {
107
122
  yield next.value;
108
123
  }
109
124
  })();
110
- const queue = new AsyncQueue();
125
+ const queue = new AsyncQueue(this.queueCapacity);
111
126
  let itemsProcessed = 0;
127
+ // Output items written this run. Equals quads written for a plain stage;
128
+ // for a projecting stage it counts projected items (documents), one or more
129
+ // – or fewer – per root, which is what `expectsOutput`/`onProgress` report.
112
130
  let quadsGenerated = 0;
113
131
  let hasResults = false;
114
132
  const onInvalid = this.validation?.onInvalid ?? 'write';
@@ -157,25 +175,34 @@ export class Stage {
157
175
  return quads;
158
176
  }));
159
177
  const batchQuads = readerOutputs.flat();
178
+ // Validation runs on the quads, before projection – validators are
179
+ // quad-typed. 'skip'/'halt' must resolve it before writing; 'write'
180
+ // validates concurrently, below, without blocking.
181
+ let acceptedQuads = batchQuads;
160
182
  if (this.validation &&
161
183
  batchQuads.length > 0 &&
162
184
  onInvalid !== 'write') {
163
- // 'skip' or 'halt': must await validation before deciding to write.
164
- const accepted = await this.validateBuffer(batchQuads, dataset);
165
- for (const quad of accepted) {
166
- await queue.push(quad);
167
- quadsGenerated++;
168
- }
185
+ acceptedQuads = await this.validateBuffer(batchQuads, dataset);
169
186
  }
170
- else {
171
- for (const quad of batchQuads) {
172
- await queue.push(quad);
173
- quadsGenerated++;
174
- }
175
- if (this.validation && batchQuads.length > 0) {
176
- // 'write' mode: validate concurrently without blocking the write path.
177
- pendingValidations.push(this.validation.validator.validate(batchQuads, dataset));
178
- }
187
+ // The one type-changing seam: project the root-complete batch into
188
+ // output items, or pass the quads through for a plain stage (`Out`
189
+ // is `Quad` at runtime when there is no projection).
190
+ const items = this.project
191
+ ? this.project(acceptedQuads, {
192
+ dataset,
193
+ distribution,
194
+ stage: this.name,
195
+ bindings,
196
+ })
197
+ : acceptedQuads;
198
+ for await (const item of items) {
199
+ await queue.push(item);
200
+ quadsGenerated++;
201
+ }
202
+ if (this.validation &&
203
+ batchQuads.length > 0 &&
204
+ onInvalid === 'write') {
205
+ pendingValidations.push(this.validation.validator.validate(batchQuads, dataset));
179
206
  }
180
207
  itemsProcessed += bindings.length;
181
208
  options?.onProgress?.(itemsProcessed, quadsGenerated);
@@ -291,7 +318,7 @@ async function* mergeStreams(streams) {
291
318
  * via `onCount` once the stream is exhausted. Lets a streaming write enforce
292
319
  * {@link StageOptions.expectsOutput} without buffering.
293
320
  *
294
- * `onCount` fires only when the consumer drains the stream — which the pipeline
321
+ * `onCount` fires only when the consumer drains the stream – which the pipeline
295
322
  * writers do. A writer that stops early would leave the count short; callers
296
323
  * relying on it for `expectsOutput` must consume the stream fully.
297
324
  */
@@ -1 +1 @@
1
- {"version":3,"file":"fileWriter.d.ts","sourceRoot":"","sources":["../../src/writer/fileWriter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAOvC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE5E,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;IAC5C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,GAAG,CAAC;CACtC;AAgBD;;;;;GAKG;AACH,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAED;;;;;;;;;;GAUG;AACH,qBAAa,UAAW,YAAW,MAAM;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;IACpD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAC9C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAyB;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAA4B;gBAE1C,OAAO,EAAE,iBAAiB;IAQhC,OAAO,CAAC,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;YAwB9C,UAAU;YAqCV,SAAS;YAOT,SAAS;YAgCT,WAAW;IAczB;;;;OAIG;YACW,cAAc;IAQ5B,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM;IAIvC,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM;IAQrC,OAAO,CAAC,WAAW;YAIL,eAAe;IA6B7B,OAAO,CAAC,YAAY;CAUrB"}
1
+ {"version":3,"file":"fileWriter.d.ts","sourceRoot":"","sources":["../../src/writer/fileWriter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAOvC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAG5E,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,MAAM,CAAC,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;IAC5C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,GAAG,CAAC;CACtC;AAgBD;;;;;GAKG;AACH,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAED;;;;;;;;;;GAUG;AACH,qBAAa,UAAW,YAAW,MAAM;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,QAAQ,CAAC,MAAM,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;IACpD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAC9C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAyB;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAA4B;gBAE1C,OAAO,EAAE,iBAAiB;IAQhC,OAAO,CAAC,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;YAwB9C,UAAU;YAsDV,SAAS;YAOT,SAAS;YAgCT,WAAW;IAczB;;;;OAIG;YACW,cAAc;IAQ5B,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM;IAIvC,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM;IAQrC,OAAO,CAAC,WAAW;YAIL,eAAe;IA6B7B,OAAO,CAAC,YAAY;CAUrB"}
@@ -3,6 +3,7 @@ import { mkdir, rename, rm } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import filenamifyUrl from 'filenamify-url';
5
5
  import { DataFactory, Writer as N3Writer } from 'n3';
6
+ import { skolemizeBlankNodes } from './skolemizeBlankNodes.js';
6
7
  const formatMap = {
7
8
  turtle: 'Turtle',
8
9
  'n-triples': 'N-Triples',
@@ -71,6 +72,22 @@ export class FileWriter {
71
72
  const addQuad = (quad) => n3Writer.addQuad(graphNode
72
73
  ? DataFactory.quad(quad.subject, quad.predicate, quad.object, graphNode)
73
74
  : quad);
75
+ // n-quads files are cat-ed into one served graph, where document-scoped
76
+ // blank-node labels from different datasets collapse into one node
77
+ // (ldelements/lde#478, dataset-knowledge-graph#352/#420). Skolemise blank
78
+ // nodes to dataset-scoped IRIs so they stay distinct. Hashing the write to
79
+ // scope the IRIs needs it buffered, so only n-quads pays that cost – Turtle
80
+ // and N-Triples (single-document, offline) stream through unchanged.
81
+ if (this.format === 'n-quads') {
82
+ const batch = [first.value];
83
+ for await (const quad of { [Symbol.asyncIterator]: () => iterator }) {
84
+ batch.push(quad);
85
+ }
86
+ for (const quad of skolemizeBlankNodes(batch, dataset.iri.toString())) {
87
+ addQuad(quad);
88
+ }
89
+ return;
90
+ }
74
91
  addQuad(first.value);
75
92
  for await (const quad of { [Symbol.asyncIterator]: () => iterator }) {
76
93
  addQuad(quad);
@@ -0,0 +1,27 @@
1
+ import type { Quad } from '@rdfjs/types';
2
+ /**
3
+ * Rewrite every blank node in `quads` to a deterministic, dataset-scoped IRI,
4
+ * leaving the quads otherwise unchanged.
5
+ *
6
+ * A served store such as the Dataset Knowledge Graph assembles one graph per
7
+ * dataset by concatenating every per-dataset n-quads file into a single index
8
+ * (`qlever index` over the `cat` of all files). Document-scoped blank-node
9
+ * labels are not preserved across that concatenation: two datasets' `_:b0`
10
+ * collapse into one node, silently fusing unrelated measurements, activities
11
+ * and linksets across datasets (see ldelements/lde#478 and
12
+ * netwerk-digitaal-erfgoed/dataset-knowledge-graph#352 / #420). IRIs are never
13
+ * relabelled, so skolemising each blank node keeps distinct nodes distinct.
14
+ *
15
+ * Each blank node becomes `<dataset>/.well-known/skolem#<batch>-<label>`. The
16
+ * dataset IRI rules out fusion across datasets; `<batch>`, a content hash of
17
+ * this write's quads, rules out fusion across the separate writes that land in
18
+ * one dataset's file – their labels both restart at `b0`, but writes carrying
19
+ * different content hash differently. Two byte-identical writes collapse onto
20
+ * the same IRIs, which is correct: they carry the same nodes.
21
+ *
22
+ * @param quads - The quads to rewrite, possibly containing blank nodes.
23
+ * @param datasetIri - The dataset the quads belong to; scopes every minted IRI.
24
+ * @returns The same quads with every blank node replaced by a skolem IRI.
25
+ */
26
+ export declare function skolemizeBlankNodes(quads: Quad[], datasetIri: string): Quad[];
27
+ //# sourceMappingURL=skolemizeBlankNodes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skolemizeBlankNodes.d.ts","sourceRoot":"","sources":["../../src/writer/skolemizeBlankNodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,IAAI,EAKL,MAAM,cAAc,CAAC;AAMtB;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,EAAE,CAgB7E"}
@@ -0,0 +1,53 @@
1
+ import { DataFactory } from 'n3';
2
+ import { hashSuffix, skolemIri } from '@lde/dataset';
3
+ const { namedNode, quad: makeQuad } = DataFactory;
4
+ /**
5
+ * Rewrite every blank node in `quads` to a deterministic, dataset-scoped IRI,
6
+ * leaving the quads otherwise unchanged.
7
+ *
8
+ * A served store such as the Dataset Knowledge Graph assembles one graph per
9
+ * dataset by concatenating every per-dataset n-quads file into a single index
10
+ * (`qlever index` over the `cat` of all files). Document-scoped blank-node
11
+ * labels are not preserved across that concatenation: two datasets' `_:b0`
12
+ * collapse into one node, silently fusing unrelated measurements, activities
13
+ * and linksets across datasets (see ldelements/lde#478 and
14
+ * netwerk-digitaal-erfgoed/dataset-knowledge-graph#352 / #420). IRIs are never
15
+ * relabelled, so skolemising each blank node keeps distinct nodes distinct.
16
+ *
17
+ * Each blank node becomes `<dataset>/.well-known/skolem#<batch>-<label>`. The
18
+ * dataset IRI rules out fusion across datasets; `<batch>`, a content hash of
19
+ * this write's quads, rules out fusion across the separate writes that land in
20
+ * one dataset's file – their labels both restart at `b0`, but writes carrying
21
+ * different content hash differently. Two byte-identical writes collapse onto
22
+ * the same IRIs, which is correct: they carry the same nodes.
23
+ *
24
+ * @param quads - The quads to rewrite, possibly containing blank nodes.
25
+ * @param datasetIri - The dataset the quads belong to; scopes every minted IRI.
26
+ * @returns The same quads with every blank node replaced by a skolem IRI.
27
+ */
28
+ export function skolemizeBlankNodes(quads, datasetIri) {
29
+ const base = `${datasetIri}/.well-known/skolem#${hashSuffix(fingerprint(quads))}`;
30
+ const skolemize = (term) => term.termType === 'BlankNode'
31
+ ? namedNode(skolemIri(base, term.value))
32
+ : term;
33
+ // A skolemised blank node is a NamedNode, valid in every term position, so the
34
+ // casts hold: skolemize only rewrites blank nodes and leaves other terms as-is.
35
+ return quads.map((quad) => makeQuad(skolemize(quad.subject), quad.predicate, skolemize(quad.object), skolemize(quad.graph)));
36
+ }
37
+ /** A deterministic string identifying a write's quads, for a per-write IRI segment. */
38
+ function fingerprint(quads) {
39
+ return quads
40
+ .map((quad) => `${termKey(quad.subject)} ${quad.predicate.value} ${termKey(quad.object)}`)
41
+ .sort()
42
+ .join('\n');
43
+ }
44
+ function termKey(term) {
45
+ switch (term.termType) {
46
+ case 'BlankNode':
47
+ return `_:${term.value}`;
48
+ case 'Literal':
49
+ return `"${term.value}"^^<${term.datatype.value}>@${term.language}`;
50
+ default:
51
+ return `<${term.value}>`;
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/pipeline",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/ldelements/lde.git",
6
6
  "directory": "packages/pipeline"
@@ -33,13 +33,13 @@
33
33
  "@rdfjs/namespace": "^2.0.1",
34
34
  "@rdfjs/types": "^2.0.1",
35
35
  "@tpluscode/rdf-ns-builders": "^5.0.0",
36
- "@traqula/generator-sparql-1-1": "^1.1.6",
36
+ "@traqula/generator-sparql-1-1": "^1.1.8",
37
37
  "@traqula/parser-sparql-1-1": "^1.1.5",
38
38
  "@traqula/rules-sparql-1-1": "^1.1.0",
39
39
  "fetch-sparql-endpoint": "^7.1.1",
40
40
  "filenamify-url": "^4.0.0",
41
41
  "is-network-error": "^1.3.2",
42
- "n3": "^2.1.0",
42
+ "n3": "^2.1.1",
43
43
  "p-retry": "^8.0.0",
44
44
  "rdf-string": "^2.0.1",
45
45
  "tslib": "^2.3.0"