@lde/search-pipeline 0.7.2 → 0.8.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
@@ -6,7 +6,8 @@ and a search engine’s document world, so indexing reuses the existing spine
6
6
  Discovery → Selection → Iteration → change-gate (skip-unchanged) – instead of
7
7
  rebuilding it per consumer.
8
8
 
9
- The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transaction-aware.md)):
9
+ The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transaction-aware.md),
10
+ [ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)):
10
11
 
11
12
  - [`@lde/search`](../search) owns the engine-agnostic projection (framed
12
13
  document + field model) and stays pipeline-free;
@@ -14,18 +15,32 @@ The division of labour ([ADR 6](../../docs/decisions/0006-make-the-writer-transa
14
15
  is the engine adapter: a transactional, **single-collection** `Writer`
15
16
  consuming projected documents, owning the run lifecycle (Blue/green alias swap
16
17
  or In-place sweeps, cross-pod lock);
17
- - **this package** composes them: `searchIndexWriter` is the one type-changing
18
- step (quad → document), shared across engines, and – since one schema declares
19
- several root types – it fans each document out to the engine writer for **its**
20
- type’s collection ([ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md)).
18
+ - **this package** composes them:
19
+ - `searchStages` builds one projecting `Stage` per root type. Each stage
20
+ selects its own roots, extracts each root’s quads, and projects the
21
+ **root-complete batch** into documents paired with their `SearchType`
22
+ (`TypedSearchDocument`) – so projection happens inside the batch and memory
23
+ is bounded by `batchSize` roots, not the dataset
24
+ ([ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md));
25
+ - `searchIndexWriter` is the pipeline’s single terminal: an engine-agnostic
26
+ router that dispatches each document to the engine writer for **its**
27
+ type’s collection ([ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md)).
28
+ It owns no projection and buffers nothing.
29
+
30
+ So a search pipeline is **one terminal, N stages**: `new Pipeline<TypedSearchDocument>({ datasetSelector, stages: searchStages(...), writers: searchIndexWriter(...) })`.
21
31
 
22
32
  ## Usage
23
33
 
24
34
  ```typescript
25
- import { Pipeline, Stage, SparqlConstructReader } from '@lde/pipeline';
35
+ import { Pipeline, SparqlConstructReader } from '@lde/pipeline';
26
36
  import { searchSchema } from '@lde/search';
27
37
  import { BlueGreenRebuild } from '@lde/search-typesense';
28
- import { searchIndexWriter } from '@lde/search-pipeline';
38
+ import {
39
+ searchStages,
40
+ selectByClass,
41
+ searchIndexWriter,
42
+ type TypedSearchDocument,
43
+ } from '@lde/search-pipeline';
29
44
 
30
45
  // One schema, several root types: the `datasets` catalog plus the Organization
31
46
  // label collection its references resolve against.
@@ -60,14 +75,33 @@ const schema = searchSchema(
60
75
  },
61
76
  );
62
77
 
63
- const pipeline = new Pipeline({
78
+ const dataset = schema.get('http://www.w3.org/ns/dcat#Dataset')!;
79
+ const organization = schema.get('http://xmlns.com/foaf/0.1/Organization')!;
80
+
81
+ const pipeline = new Pipeline<TypedSearchDocument>({
64
82
  datasetSelector,
65
- stages: [
66
- new Stage({
67
- name: 'extract',
68
- readers: new SparqlConstructReader({ query: '…' }),
69
- }),
70
- ],
83
+ // One stage per root type: each selects its own roots (here by class –
84
+ // `selectByClass` is a convenience for the object grain; a deployment writes
85
+ // its own selector where the entry point is a domain fact) and extracts each
86
+ // root’s quads. `rootVariable` couples the selector’s projected variable to
87
+ // what the projection reads; it must not be `dataset` (reserved by the reader).
88
+ stages: searchStages({
89
+ schema,
90
+ types: [
91
+ {
92
+ searchType: dataset,
93
+ rootVariable: 'root',
94
+ itemSelector: selectByClass(dataset),
95
+ readers: new SparqlConstructReader({ query: '…' }),
96
+ },
97
+ {
98
+ searchType: organization,
99
+ rootVariable: 'root',
100
+ itemSelector: selectByClass(organization),
101
+ readers: new SparqlConstructReader({ query: '…' }),
102
+ },
103
+ ],
104
+ }),
71
105
  // Each type gets its own collection – an independent blue/green rebuild –
72
106
  // named by the adapter from the type itself (`Dataset` → `datasets`,
73
107
  // `Organization` → `organizations`), so no naming map is passed here and the
@@ -101,14 +135,21 @@ Whatever the writers are named, the engine reading these collections must be
101
135
  given the same names (`collections`), or it reads the derived ones and finds an
102
136
  empty index.
103
137
 
104
- Each dataset’s extracted CONSTRUCT quads are buffered until the dataset
105
- completes, then projected once over the **whole** schema (`@lde/search`’s
106
- `projectGraph`) into one mixed, type-tagged stream, and each document is
107
- dispatched to the engine run for its type’s collection – before the engine acts
108
- on the dataset’s completion, so an In-place stale sweep never races its own
109
- documents. The run lifecycle (run context, per-dataset flush outcome,
110
- commit/abort) passes through unchanged: the engine writer’s update mode governs,
111
- and the pipeline never branches on it, nor on how many collections there are.
138
+ Each stage projects its own root-complete batches (`@lde/search`’s
139
+ `projectRoots`) into documents paired with its `SearchType`, and the terminal
140
+ dispatches each to the engine run for its type’s collection as it arrives –
141
+ before the engine acts on the dataset’s completion, so an In-place stale sweep
142
+ never races its own documents. The run lifecycle (run context, per-dataset flush
143
+ outcome, commit/abort) passes through unchanged: the engine writer’s update mode
144
+ governs, and the pipeline never branches on it, nor on how many collections
145
+ there are.
146
+
147
+ **Root selection is the deployment’s contract.** A selector that does not bind
148
+ the CONSTRUCT’s subject yields a batch that is not root-complete, and projection
149
+ will then silently emit partial documents – a promise `@lde/pipeline` cannot
150
+ check ([ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
151
+ `selectByClass(searchType)` is a convenience for the object grain (where the
152
+ type’s `class` really is the source class), **not** a default.
112
153
 
113
154
  ## Per-collection isolation
114
155
 
@@ -130,7 +171,12 @@ See [ADR 9](../../docs/decisions/0009-route-a-whole-schema-projection-to-per-typ
130
171
 
131
172
  ## Memory
132
173
 
133
- Memory is bounded by one dataset’s extraction and released at each dataset
134
- flush – nothing accumulates across datasets. Streaming bounded entity batches
135
- _within_ one huge dataset needs the two-level iteration (dataset →
136
- entity-URI batches) and is not implemented yet.
174
+ Memory is bounded by **`batchSize` roots**, not by the input
175
+ ([ADR 12](../../docs/decisions/0012-bound-memory-by-the-unit-of-work-not-the-input.md),
176
+ [ADR 13](../../docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
177
+ Projection runs inside each root-complete batch and streams straight through the
178
+ terminal to the engine run, which holds only its own batch – nothing accumulates
179
+ across a dataset or across datasets, so the same pipeline indexes a 15-quad
180
+ catalog entry and a dataset of millions of objects. The one irreducible atom is a
181
+ single root’s quads: unbounded for a pathological root, and nothing here changes
182
+ that.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export { searchStages, selectByClass } from './search-stages.js';
2
+ export type { SearchStagesOptions, SearchStageType } from './search-stages.js';
1
3
  export { searchIndexWriter } from './search-index-writer.js';
2
4
  export type { SearchIndexWriterOptions } from './search-index-writer.js';
5
+ export type { TypedSearchDocument } from './typed-search-document.js';
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACjE,YAAY,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAC;AACzE,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
+ export { searchStages, selectByClass } from './search-stages.js';
1
2
  export { searchIndexWriter } from './search-index-writer.js';
@@ -1,12 +1,12 @@
1
- import type { Quad } from '@rdfjs/types';
2
- import type { Writer } from '@lde/pipeline';
3
- import { type SearchDocument, type SearchSchema, type SearchType } from '@lde/search';
1
+ import { type Writer } from '@lde/pipeline';
2
+ import type { SearchDocument, SearchSchema, SearchType } from '@lde/search';
3
+ import type { TypedSearchDocument } from './typed-search-document.js';
4
4
  /** Options for {@link searchIndexWriter}. */
5
5
  export interface SearchIndexWriterOptions {
6
6
  /**
7
- * The declarative schema driving the projection: one {@link SearchType} per
8
- * root type, each mapping framed RDF to flat search-document fields. The
9
- * writer opens one engine run per type in it.
7
+ * The declarative schema: one {@link SearchType} per root type. The writer
8
+ * opens one engine run per type in it and routes each document to its type’s
9
+ * run. Must be the same schema the stages project through.
10
10
  */
11
11
  schema: SearchSchema;
12
12
  /**
@@ -19,19 +19,22 @@ export interface SearchIndexWriterOptions {
19
19
  * multi-collection deployment (the Dataset Register’s `datasets` plus its
20
20
  * Organization / Class / TerminologySource label collections) returns a
21
21
  * distinct writer per type, each an independent blue/green rebuild with its
22
- * own collection, alias and cross-pod lock. The projection is whole-schema
23
- * either way; the per-collection fan-out is this writer’s job.
22
+ * own collection, alias and cross-pod lock. The per-collection fan-out is this
23
+ * writer’s job.
24
24
  */
25
25
  writerFor: (searchType: SearchType) => Writer<SearchDocument>;
26
26
  }
27
27
  /**
28
- * The projection step of a search-indexing pipeline, as a quad `Writer`, fanned
29
- * out across a type’s collections. It turns each dataset’s extracted CONSTRUCT
30
- * quads into engine-agnostic search documents ({@link projectGraph}: frame by
31
- * root type, then project each node with its type’s declaration) and dispatches
32
- * each document to the engine run for **its** type. This is the one
33
- * type-changing step (quad → document), shared across engines – which is why it
34
- * lives here and not inside an engine adapter.
28
+ * The single terminal of a search-indexing pipeline: an engine-agnostic router
29
+ * that fans already-projected documents out across a type’s collections. The
30
+ * per-type {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | stages}
31
+ * project inside the batch and pair each document with its {@link SearchType}
32
+ * ({@link TypedSearchDocument}); this writer dispatches each document to the
33
+ * engine run for **its** type by `searchType.class`. It **owns no projection**
34
+ * (that moved into the stages) and **buffers nothing** (documents stream through
35
+ * to the run as they arrive) – it is purely the per-collection fan-out
36
+ * {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}
37
+ * made it.
35
38
  *
36
39
  * Each root type is an independent engine run (its own collection, alias and
37
40
  * lock), so the collections commit, sweep and fail in isolation:
@@ -48,12 +51,9 @@ export interface SearchIndexWriterOptions {
48
51
  * - `abort` (a run failure, or a partial commit) drops every half-built
49
52
  * collection that has not committed and leaves the live ones untouched.
50
53
  *
51
- * A dataset’s quads are buffered until its flush and projected then, so the
52
- * documents land before every engine acts on the dataset’s completion (e.g. an
53
- * In-place stale sweep). Memory is bounded by one dataset’s extraction, and
54
- * released at each flush – nothing accumulates across datasets. Streaming
55
- * bounded entity batches *within* one huge dataset needs the two-level
56
- * iteration (dataset → entity-URI batches) and is not implemented yet.
54
+ * Memory is bounded by one batch of documents per type, not the dataset:
55
+ * `write` routes each document to its type’s run through a bounded queue and
56
+ * never accumulates them.
57
57
  */
58
- export declare function searchIndexWriter(options: SearchIndexWriterOptions): Writer<Quad>;
58
+ export declare function searchIndexWriter(options: SearchIndexWriterOptions): Writer<TypedSearchDocument>;
59
59
  //# sourceMappingURL=search-index-writer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,KAAK,EAIV,MAAM,EACP,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,SAAS,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC,cAAc,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,IAAI,CAAC,CA2Id"}
1
+ {"version":3,"file":"search-index-writer.d.ts","sourceRoot":"","sources":["../src/search-index-writer.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,6CAA6C;AAC7C,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,SAAS,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC,cAAc,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,MAAM,CAAC,mBAAmB,CAAC,CAoI7B"}
@@ -1,12 +1,15 @@
1
- import { projectGraph, } from '@lde/search';
1
+ import { AsyncQueue, } from '@lde/pipeline';
2
2
  /**
3
- * The projection step of a search-indexing pipeline, as a quad `Writer`, fanned
4
- * out across a type’s collections. It turns each dataset’s extracted CONSTRUCT
5
- * quads into engine-agnostic search documents ({@link projectGraph}: frame by
6
- * root type, then project each node with its type’s declaration) and dispatches
7
- * each document to the engine run for **its** type. This is the one
8
- * type-changing step (quad → document), shared across engines – which is why it
9
- * lives here and not inside an engine adapter.
3
+ * The single terminal of a search-indexing pipeline: an engine-agnostic router
4
+ * that fans already-projected documents out across a type’s collections. The
5
+ * per-type {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md | stages}
6
+ * project inside the batch and pair each document with its {@link SearchType}
7
+ * ({@link TypedSearchDocument}); this writer dispatches each document to the
8
+ * engine run for **its** type by `searchType.class`. It **owns no projection**
9
+ * (that moved into the stages) and **buffers nothing** (documents stream through
10
+ * to the run as they arrive) – it is purely the per-collection fan-out
11
+ * {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}
12
+ * made it.
10
13
  *
11
14
  * Each root type is an independent engine run (its own collection, alias and
12
15
  * lock), so the collections commit, sweep and fail in isolation:
@@ -23,17 +26,14 @@ import { projectGraph, } from '@lde/search';
23
26
  * - `abort` (a run failure, or a partial commit) drops every half-built
24
27
  * collection that has not committed and leaves the live ones untouched.
25
28
  *
26
- * A dataset’s quads are buffered until its flush and projected then, so the
27
- * documents land before every engine acts on the dataset’s completion (e.g. an
28
- * In-place stale sweep). Memory is bounded by one dataset’s extraction, and
29
- * released at each flush – nothing accumulates across datasets. Streaming
30
- * bounded entity batches *within* one huge dataset needs the two-level
31
- * iteration (dataset → entity-URI batches) and is not implemented yet.
29
+ * Memory is bounded by one batch of documents per type, not the dataset:
30
+ * `write` routes each document to its type’s run through a bounded queue and
31
+ * never accumulates them.
32
32
  */
33
33
  export function searchIndexWriter(options) {
34
34
  const { schema, writerFor } = options;
35
35
  // One engine writer per root type, built once; each run opens them all. Keyed
36
- // by the type IRI, which is also how a projected document names its type.
36
+ // by the type IRI, which is also how a paired document names its type.
37
37
  const writers = new Map([...schema.values()].map((searchType) => [
38
38
  searchType.class,
39
39
  writerFor(searchType),
@@ -55,54 +55,49 @@ export function searchIndexWriter(options) {
55
55
  // The collections that have gone live, so `abort` never re-finalizes one
56
56
  // (a committed blue/green rebuild’s abort drops its now-live collection).
57
57
  const committed = new Set();
58
- // One buffered pass per dataset, keyed by IRI. The pipeline processes
59
- // datasets sequentially, but keeping the key explicit makes a write after
60
- // another dataset's flush safe too.
61
- const passes = new Map();
62
- const project = async (pass) => {
63
- passes.delete(pass.dataset.iri.toString());
64
- if (pass.quads.length === 0) {
65
- return;
66
- }
67
- // Whole-schema projection into one mixed stream, split by type here so
68
- // each type’s documents reach its own collection’s run.
69
- const byType = new Map();
70
- for await (const { searchType, document } of projectGraph(pass.quads, schema)) {
71
- const documents = byType.get(searchType.class);
72
- if (documents === undefined) {
73
- byType.set(searchType.class, [document]);
74
- }
75
- else {
76
- documents.push(document);
77
- }
78
- }
79
- // Every yielded type is a schema type, so it has a run; iterate the
80
- // runs and write each the documents projected for its type (none, for a
81
- // type absent from this pass).
82
- for (const [typeIri, run] of runs) {
83
- const documents = byType.get(typeIri);
84
- if (documents !== undefined) {
85
- await run.write(pass.dataset, stream(documents));
86
- }
87
- }
88
- };
89
58
  return {
90
- write: async (dataset, quads) => {
91
- const key = dataset.iri.toString();
92
- let pass = passes.get(key);
93
- if (pass === undefined) {
94
- pass = { dataset, quads: [] };
95
- passes.set(key, pass);
59
+ write: async (dataset, items) => {
60
+ // Route each document to its type’s run, streaming. A stage writes
61
+ // one type, but the terminal carries no stage identity, so it routes
62
+ // per item by its `searchType`. Each type gets one `run.write`, fed a
63
+ // bounded queue the run drains concurrently – so memory stays O(batch)
64
+ // per type, never O(dataset). Almost always one lane per call.
65
+ const lanes = new Map();
66
+ const laneFor = (searchType) => {
67
+ let lane = lanes.get(searchType.class);
68
+ if (lane === undefined) {
69
+ const run = runs.get(searchType.class);
70
+ if (run === undefined) {
71
+ throw new Error(`No engine run for search type “${searchType.name}” (${searchType.class}); it is not in this writer’s schema.`);
72
+ }
73
+ const queue = new AsyncQueue();
74
+ const done = run.write(dataset, queue);
75
+ // If the run stops consuming (its write rejects), unblock the
76
+ // producer so a full queue cannot deadlock the push loop below.
77
+ done.catch((error) => queue.abort(error));
78
+ lane = { queue, done };
79
+ lanes.set(searchType.class, lane);
80
+ }
81
+ return lane;
82
+ };
83
+ try {
84
+ for await (const { searchType, document } of items) {
85
+ await laneFor(searchType).queue.push(document);
86
+ }
87
+ for (const lane of lanes.values()) {
88
+ lane.queue.close();
89
+ }
90
+ await Promise.all([...lanes.values()].map((lane) => lane.done));
96
91
  }
97
- for await (const quad of quads) {
98
- pass.quads.push(quad);
92
+ catch (error) {
93
+ for (const lane of lanes.values()) {
94
+ lane.queue.abort(error);
95
+ }
96
+ await Promise.allSettled([...lanes.values()].map((lane) => lane.done));
97
+ throw error;
99
98
  }
100
99
  },
101
100
  flush: async (dataset, outcome) => {
102
- const pass = passes.get(dataset.iri.toString());
103
- if (pass !== undefined) {
104
- await project(pass);
105
- }
106
101
  // Flush every collection independently: one collection’s flush failure
107
102
  // (a rollback, or an In-place stale sweep) must not skip another’s –
108
103
  // the pipeline isolates a flush error per dataset and still commits,
@@ -113,19 +108,12 @@ export function searchIndexWriter(options) {
113
108
  await settleAll(runs.values(), 'flush', (run) => run.flush?.(dataset, outcome));
114
109
  },
115
110
  reset: async (dataset) => {
116
- // Discard the buffered pass so the re-run replaces it, and let every
117
- // collection’s run discard whatever it already holds for the dataset –
118
- // independently, so one collection’s reset failure never leaves
119
- // another holding the discarded pass’s documents into the re-run.
120
- passes.delete(dataset.iri.toString());
111
+ // Let every collection’s run discard whatever it already holds for the
112
+ // dataset – independently, so one collection’s reset failure never
113
+ // leaves another holding the discarded documents into the re-run.
121
114
  await settleAll(runs.values(), 'reset', (run) => run.reset?.(dataset));
122
115
  },
123
116
  commit: async () => {
124
- // Safety net: project passes that were never flushed, so a committed
125
- // run never silently drops written quads.
126
- for (const pass of [...passes.values()]) {
127
- await project(pass);
128
- }
129
117
  // Commit every collection independently, so one failure neither
130
118
  // blocks nor wipes another – the `datasets` index goes live even if a
131
119
  // label collection cannot. Record each collection that went live, so
@@ -162,7 +150,3 @@ async function settleAll(items, verb, operation) {
162
150
  throw new AggregateError(failures, `${failures.length} of ${targets.length} search collections failed to ${verb}`);
163
151
  }
164
152
  }
165
- /** Yield the given documents as an async iterable, like a streaming source. */
166
- async function* stream(items) {
167
- yield* items;
168
- }
@@ -0,0 +1,82 @@
1
+ import { Stage, type ItemSelector, type StageReaders } from '@lde/pipeline';
2
+ import { type SearchSchema, type SearchType } from '@lde/search';
3
+ import type { TypedSearchDocument } from './typed-search-document.js';
4
+ /** One root type’s stage in a search pipeline. */
5
+ export interface SearchStageType {
6
+ /**
7
+ * The {@link SearchType} this stage projects. Must belong to
8
+ * {@link SearchStagesOptions.schema} (matched by `class`); the stage projects
9
+ * with the schema’s own declaration object, so `assertTypeInSchema`’s identity
10
+ * check inside {@link projectRoots} always holds.
11
+ */
12
+ searchType: SearchType;
13
+ /**
14
+ * The selector variable that binds this type’s roots – the CONSTRUCT subject
15
+ * the batch is complete for. Must **not** be `dataset`: `?dataset` is
16
+ * substituted with the dataset IRI by the SPARQL reader, so a root bound to it
17
+ * would never reach the projection. {@link selectByClass} defaults to `root`.
18
+ */
19
+ rootVariable: string;
20
+ /**
21
+ * Selects this type’s roots, one binding of {@link rootVariable} per root.
22
+ * Root selection is the deployment’s concern; {@link selectByClass} is a
23
+ * convenience for the object grain, not a default.
24
+ */
25
+ itemSelector: ItemSelector;
26
+ /** Reader(s) that extract each selected root’s quads. */
27
+ readers: StageReaders;
28
+ /**
29
+ * Roots (and so documents) per batch – the memory bound. Under a root-bound
30
+ * selector it moves memory and request count, never output.
31
+ * @default 10
32
+ */
33
+ batchSize?: number;
34
+ /** Maximum concurrent in-flight SPARQL queries for this stage. @default 10 */
35
+ maxConcurrency?: number;
36
+ /**
37
+ * Capacity of the bounded queue funnelling this stage’s projected documents
38
+ * into the write. A projected document is far heavier than a quad, so lower it
39
+ * where documents are large. @default 128
40
+ */
41
+ queueCapacity?: number;
42
+ }
43
+ /** Options for {@link searchStages}. */
44
+ export interface SearchStagesOptions {
45
+ /**
46
+ * The declarative schema driving projection: one {@link SearchType} per root
47
+ * type. Every {@link SearchStageType.searchType} must be a member of it.
48
+ */
49
+ schema: SearchSchema;
50
+ /** One entry per root type to index, each its own stage. */
51
+ types: readonly SearchStageType[];
52
+ }
53
+ /**
54
+ * Compose one projecting {@link Stage} per root type – the source side of a
55
+ * search pipeline. Each stage selects its own roots, extracts each root’s quads,
56
+ * and projects the root-complete batch into {@link TypedSearchDocument}s
57
+ * ({@link projectRoots} + the `searchType` pair), which the pipeline’s single
58
+ * {@link searchIndexWriter} terminal routes to that type’s collection. Projection
59
+ * happens **inside the batch**, so memory is bounded by `batchSize` roots, never
60
+ * by the dataset
61
+ * ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
62
+ *
63
+ * Wire the result as `new Pipeline<TypedSearchDocument>({ datasetSelector,
64
+ * stages: searchStages(...), writers: searchIndexWriter(...) })` – one terminal,
65
+ * N stages.
66
+ */
67
+ export declare function searchStages(options: SearchStagesOptions): Stage<TypedSearchDocument>[];
68
+ /**
69
+ * An {@link ItemSelector} that selects every instance of a root type’s source
70
+ * class: `SELECT ?‹rootVariable› WHERE { ?‹rootVariable› a <class> }`. A
71
+ * convenience for the **object grain**, where {@link SearchType.class} really is
72
+ * the source class – **not** a default: root selection is a deployment concern,
73
+ * and three of the Dataset Register’s four catalog types have no source class at
74
+ * all (their entry point – “registered, newest registration, has a title” – is a
75
+ * deployment fact no schema states).
76
+ *
77
+ * `rootVariable` defaults to `root` and must match the stage’s
78
+ * {@link SearchStageType.rootVariable}; it must not be `dataset` (reserved by the
79
+ * SPARQL reader).
80
+ */
81
+ export declare function selectByClass(searchType: SearchType, rootVariable?: string): ItemSelector;
82
+ //# sourceMappingURL=search-stages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-stages.d.ts","sourceRoot":"","sources":["../src/search-stages.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,EACL,KAAK,YAAY,EACjB,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAgB,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,UAAU,EAAE,UAAU,CAAC;IACvB;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,EAAE,YAAY,CAAC;IAC3B,yDAAyD;IACzD,OAAO,EAAE,YAAY,CAAC;IACtB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wCAAwC;AACxC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,EAAE,YAAY,CAAC;IACrB,4DAA4D;IAC5D,KAAK,EAAE,SAAS,eAAe,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,mBAAmB,GAC3B,KAAK,CAAC,mBAAmB,CAAC,EAAE,CA8C9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,UAAU,EAAE,UAAU,EACtB,YAAY,SAAS,GACpB,YAAY,CAId"}
@@ -0,0 +1,72 @@
1
+ import { SparqlItemSelector, Stage, } from '@lde/pipeline';
2
+ import { projectRoots } from '@lde/search';
3
+ /**
4
+ * Compose one projecting {@link Stage} per root type – the source side of a
5
+ * search pipeline. Each stage selects its own roots, extracts each root’s quads,
6
+ * and projects the root-complete batch into {@link TypedSearchDocument}s
7
+ * ({@link projectRoots} + the `searchType` pair), which the pipeline’s single
8
+ * {@link searchIndexWriter} terminal routes to that type’s collection. Projection
9
+ * happens **inside the batch**, so memory is bounded by `batchSize` roots, never
10
+ * by the dataset
11
+ * ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
12
+ *
13
+ * Wire the result as `new Pipeline<TypedSearchDocument>({ datasetSelector,
14
+ * stages: searchStages(...), writers: searchIndexWriter(...) })` – one terminal,
15
+ * N stages.
16
+ */
17
+ export function searchStages(options) {
18
+ const { schema, types } = options;
19
+ return types.map((type) => {
20
+ // Project with the schema’s OWN declaration object, whatever the caller
21
+ // passed: `assertTypeInSchema` (inside `projectRoots`) is an identity check,
22
+ // and re-resolving here makes a class-equal lookalike work too.
23
+ const searchType = schema.get(type.searchType.class);
24
+ if (searchType === undefined) {
25
+ throw new Error(`Search type “${type.searchType.name}” (class ${type.searchType.class}) is not in the schema; searchStages projects only types the schema declares.`);
26
+ }
27
+ const { rootVariable } = type;
28
+ return new Stage({
29
+ name: searchType.name,
30
+ readers: type.readers,
31
+ itemSelector: type.itemSelector,
32
+ batchSize: type.batchSize,
33
+ maxConcurrency: type.maxConcurrency,
34
+ queueCapacity: type.queueCapacity,
35
+ project: async function* (quads, context) {
36
+ // The batch is root-complete by construction: `context.bindings` are the
37
+ // selector rows the readers ran with, so these are exactly this batch’s
38
+ // roots. Project them, then re-attach the type the stage was built for.
39
+ const roots = context.bindings.map((binding) => {
40
+ const term = binding[rootVariable];
41
+ if (term === undefined) {
42
+ // The selector projected a different variable than the stage reads:
43
+ // a config mismatch. Fail loudly rather than deref `undefined`.
44
+ throw new Error(`Stage “${searchType.name}”: selector did not bind ?${rootVariable} – the stage’s rootVariable must match the selector’s projected variable.`);
45
+ }
46
+ return term.value;
47
+ });
48
+ for await (const document of projectRoots(quads, roots, schema, searchType)) {
49
+ yield { searchType, document };
50
+ }
51
+ },
52
+ });
53
+ });
54
+ }
55
+ /**
56
+ * An {@link ItemSelector} that selects every instance of a root type’s source
57
+ * class: `SELECT ?‹rootVariable› WHERE { ?‹rootVariable› a <class> }`. A
58
+ * convenience for the **object grain**, where {@link SearchType.class} really is
59
+ * the source class – **not** a default: root selection is a deployment concern,
60
+ * and three of the Dataset Register’s four catalog types have no source class at
61
+ * all (their entry point – “registered, newest registration, has a title” – is a
62
+ * deployment fact no schema states).
63
+ *
64
+ * `rootVariable` defaults to `root` and must match the stage’s
65
+ * {@link SearchStageType.rootVariable}; it must not be `dataset` (reserved by the
66
+ * SPARQL reader).
67
+ */
68
+ export function selectByClass(searchType, rootVariable = 'root') {
69
+ return new SparqlItemSelector({
70
+ query: `SELECT ?${rootVariable} WHERE { ?${rootVariable} a <${searchType.class}> }`,
71
+ });
72
+ }
@@ -0,0 +1,23 @@
1
+ import type { SearchDocument, SearchType } from '@lde/search';
2
+ /**
3
+ * A projected {@link SearchDocument} paired with the {@link SearchType} it was
4
+ * projected from.
5
+ *
6
+ * A search pipeline is N per-type stages writing to one terminal, and
7
+ * {@link https://github.com/ldelements/lde/blob/main/docs/decisions/0009-route-a-whole-schema-projection-to-per-type-collections.md | ADR 9}’s
8
+ * `searchIndexWriter` receives `write(dataset, items)` with no stage identity –
9
+ * so the type travels with the item and the writer routes each document to the
10
+ * engine run for its type by `searchType.class`. A stage mints the pair itself
11
+ * (it was constructed for one type), so nothing is ever re-derived from the
12
+ * document.
13
+ *
14
+ * It lives in `@lde/search-pipeline`, not `@lde/search`, because it exists only
15
+ * because a pipeline terminal routes: that is glue, not projection. `@lde/search`
16
+ * yields a bare {@link SearchDocument} and stays pipeline-free
17
+ * ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)).
18
+ */
19
+ export interface TypedSearchDocument {
20
+ readonly searchType: SearchType;
21
+ readonly document: SearchDocument;
22
+ }
23
+ //# sourceMappingURL=typed-search-document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typed-search-document.d.ts","sourceRoot":"","sources":["../src/typed-search-document.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9D;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;CACnC"}
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-pipeline",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Search indexing as an @lde/pipeline instance: a Writer<Quad> that applies the @lde/search projection to extracted RDF and fans each document out to the transactional engine writer for its type's collection. Glue between the pipeline quad world and the search-engine document world; owns no projection or engine logic itself.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -25,12 +25,12 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.8.1",
29
- "@rdfjs/types": "^2.0.1",
28
+ "@lde/search": "^0.9.0",
30
29
  "tslib": "^2.3.0"
31
30
  },
32
31
  "devDependencies": {
33
- "@lde/search-typesense": "^0.10.2",
32
+ "@lde/search-typesense": "^0.11.0",
33
+ "@rdfjs/types": "^2.0.1",
34
34
  "n3": "^2.1.1",
35
35
  "testcontainers": "^12.0.3",
36
36
  "typesense": "^3.0.6"