@lde/search-indexer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +30 -0
- package/dist/composition.d.ts +22 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +53 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +150 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/indexer.d.ts +13 -0
- package/dist/indexer.d.ts.map +1 -0
- package/dist/indexer.js +38 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @lde/search-indexer
|
|
2
|
+
|
|
3
|
+
The [@lde/search-pipeline](../search-pipeline) indexer as a bootable process
|
|
4
|
+
and prebuilt Docker image: mount a schema-declaration module, point it at a
|
|
5
|
+
dataset registry and Typesense, and one run selects the datasets, extracts and
|
|
6
|
+
projects each root type per the schema, and rebuilds the engine collections –
|
|
7
|
+
then exits. Run it as a cron job; the rebuild writers’ cross-pod lock makes an
|
|
8
|
+
overlapping run fail fast instead of corrupting a collection.
|
|
9
|
+
|
|
10
|
+
This is the write-path counterpart of
|
|
11
|
+
[`@lde/search-api-server`](../search-api-server)
|
|
12
|
+
([#652](https://github.com/ldelements/lde/issues/652)): the composition layer
|
|
13
|
+
that binds the engine-agnostic `searchIndexerPipeline` to the
|
|
14
|
+
[`@lde/search-typesense`](../search-typesense) rebuild writers. Mount the
|
|
15
|
+
**same schema module** into both images and point both at the same
|
|
16
|
+
`TYPESENSE_*` coordinates, and the write and the read side cannot disagree
|
|
17
|
+
about the schema. A deployment that needs more – a bespoke root selector,
|
|
18
|
+
per-stage tuning, another engine – composes `searchStages`,
|
|
19
|
+
`searchIndexWriter` and `Pipeline` directly instead.
|
|
20
|
+
|
|
21
|
+
## Run
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
docker run --rm \
|
|
25
|
+
--volume ./search-schema.mjs:/config/search-schema.mjs:ro \
|
|
26
|
+
--env REGISTRY_ENDPOINT=https://registry.example.org/sparql \
|
|
27
|
+
--env TYPESENSE_HOST=typesense.internal \
|
|
28
|
+
--env TYPESENSE_API_KEY=admin-key \
|
|
29
|
+
ghcr.io/ldelements/search-indexer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or without Docker (the same environment variables apply):
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npx @lde/search-indexer
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Dataset IRIs can also be passed as arguments, which override `DATASETS`:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx @lde/search-indexer https://example.org/id/dataset/1
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`--check` validates the configuration and schema module, then exits without
|
|
45
|
+
indexing – for CI and init containers.
|
|
46
|
+
|
|
47
|
+
## The schema module
|
|
48
|
+
|
|
49
|
+
The mounted module default-exports the deployment’s search type declarations
|
|
50
|
+
as **plain data** – see [the API server’s
|
|
51
|
+
README](../search-api-server/README.md#the-schema-module) for the format and
|
|
52
|
+
authoring guidance; both images load the module with the same
|
|
53
|
+
`@lde/search/module` loader. The indexer reads only the default export: the
|
|
54
|
+
declarations drive one pipeline stage and one engine collection per root type,
|
|
55
|
+
and each field’s `path` drives the extraction CONSTRUCT. The read-side extras
|
|
56
|
+
(`schemaOptions`, `engineOptions`) are ignored here, so one file serves both
|
|
57
|
+
images.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
| Variable | Default | Meaning |
|
|
62
|
+
| -------------------- | --------------------------- | ---------------------------------------------------------------------------- |
|
|
63
|
+
| `SCHEMA_MODULE` | `/config/search-schema.mjs` | Path of the mounted schema-declaration module |
|
|
64
|
+
| `REGISTRY_ENDPOINT` | **required** | SPARQL endpoint of the DCAT dataset registry |
|
|
65
|
+
| `DATASETS` | all registry datasets | Dataset IRIs to index (whitespace- or comma-separated) |
|
|
66
|
+
| `DATASET_CRITERIA` | all registry datasets | Registry search criteria as a JSON object (mutually exclusive w/ `DATASETS`) |
|
|
67
|
+
| `TYPESENSE_HOST` | **required** | Typesense host |
|
|
68
|
+
| `TYPESENSE_PORT` | `8108` | Typesense port |
|
|
69
|
+
| `TYPESENSE_PROTOCOL` | `http` | `http` or `https` |
|
|
70
|
+
| `TYPESENSE_API_KEY` | **required** | An admin key: the indexer creates, writes and swaps collections |
|
|
71
|
+
| `REBUILD_MODE` | `in-place` | `in-place` (update the live collection) or `blue-green` (swap on commit) |
|
|
72
|
+
| `COLLECTION_PREFIX` | none | Prefix for every derived collection name (configure the read side to match) |
|
|
73
|
+
| `PROVENANCE_FILE` | none | JSON file remembering per-dataset processing, to skip unchanged datasets |
|
|
74
|
+
| `PIPELINE_VERSION` | none | Version keying the skip decisions; required with `PROVENANCE_FILE` |
|
|
75
|
+
| `QLEVER_IMAGE` | none | Enables the QLever import path (see below), e.g. `adfreiburg/qlever:latest` |
|
|
76
|
+
| `IMPORT_STRATEGY` | `sparql` | `sparql`, `sparqlWithImportFallback` or `import`; requires `QLEVER_IMAGE` |
|
|
77
|
+
| `DATA_DIR` | `/data` | Directory for downloaded dumps and QLever index caches |
|
|
78
|
+
|
|
79
|
+
A misconfigured boot reports **all** problems in one error, not one per crash
|
|
80
|
+
loop. `PROVENANCE_FILE` must sit on a durable volume, and cannot be combined
|
|
81
|
+
with `REBUILD_MODE=blue-green`: a skipped dataset would be missing from the
|
|
82
|
+
fresh collection the swap makes live.
|
|
83
|
+
|
|
84
|
+
## The QLever import path
|
|
85
|
+
|
|
86
|
+
By default the indexer serves only datasets that publish a live SPARQL
|
|
87
|
+
endpoint (the pipeline’s endpoint-only default). Setting `QLEVER_IMAGE`
|
|
88
|
+
enables the import path: data dumps are downloaded to `DATA_DIR` and imported
|
|
89
|
+
into a QLever instance the **pipeline itself creates and controls** – one
|
|
90
|
+
sibling container per dataset, spawned over the Docker socket
|
|
91
|
+
([ADR 16](../../docs/decisions/0016-ship-the-search-indexer-as-a-config-driven-image.md)
|
|
92
|
+
explains why a statically-declared QLever service cannot work). That requires:
|
|
93
|
+
|
|
94
|
+
- the Docker socket mounted into the indexer container
|
|
95
|
+
(`--volume /var/run/docker.sock:/var/run/docker.sock`), plus a group grant
|
|
96
|
+
to reach it as the image’s non-root user
|
|
97
|
+
(`--group-add $(stat -c %g /var/run/docker.sock)`);
|
|
98
|
+
- `DATA_DIR` on a volume whose **host path is identical** for the indexer and
|
|
99
|
+
the spawned QLever containers – the pipeline passes `DATA_DIR` as a bind
|
|
100
|
+
mount to a sibling container, where it resolves against the host.
|
|
101
|
+
|
|
102
|
+
This mode does not work on container runtimes without a Docker socket
|
|
103
|
+
(containerd-based Kubernetes); there, run endpoint-only for now.
|
|
104
|
+
|
|
105
|
+
## Building the image
|
|
106
|
+
|
|
107
|
+
The image is built from the workspace’s own outputs – the compiled package,
|
|
108
|
+
the same-commit builds of its `@lde/*` dependencies and a pruned lockfile –
|
|
109
|
+
never from npm, so it exists for any commit
|
|
110
|
+
([ADR 15](../../docs/decisions/0015-ship-the-served-search-api-as-a-docker-image-built-from-the-workspace.md)):
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
npx nx run @lde/search-indexer:docker:build # → packages-search-indexer
|
|
114
|
+
npx nx run @lde/search-indexer:docker:smoke # boots it with --check
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
CI runs `docker:smoke` for affected PRs; each release tag rebuilds and pushes
|
|
118
|
+
`ghcr.io/ldelements/search-indexer:<version>` (`.github/workflows/docker.yml`).
|
|
119
|
+
|
|
120
|
+
## Programmatic use
|
|
121
|
+
|
|
122
|
+
The bin is a thin wrapper over the exported API, usable in tests or a custom
|
|
123
|
+
boot:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import {
|
|
127
|
+
configFromEnvironment,
|
|
128
|
+
createSearchIndexer,
|
|
129
|
+
} from '@lde/search-indexer';
|
|
130
|
+
|
|
131
|
+
const pipeline = await createSearchIndexer(configFromEnvironment(process.env));
|
|
132
|
+
await pipeline.run();
|
|
133
|
+
```
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { configFromEnvironment } from './config.js';
|
|
5
|
+
import { createSearchIndexer } from './indexer.js';
|
|
6
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
7
|
+
const program = new Command()
|
|
8
|
+
.name('search-indexer')
|
|
9
|
+
.description('Run the object-grain search indexer: select datasets, extract and project each root type per the mounted schema module, and rebuild the Typesense collections. Configuration comes from environment variables; see the README.')
|
|
10
|
+
.version(version)
|
|
11
|
+
.argument('[datasets...]', 'dataset IRIs to index (overrides the DATASETS environment variable)')
|
|
12
|
+
.option('--check', 'validate the configuration and schema module, then exit without indexing');
|
|
13
|
+
program.parse();
|
|
14
|
+
try {
|
|
15
|
+
const datasets = program.args;
|
|
16
|
+
const config = configFromEnvironment(datasets.length > 0
|
|
17
|
+
? { ...process.env, DATASETS: datasets.join(' ') }
|
|
18
|
+
: process.env);
|
|
19
|
+
const pipeline = await createSearchIndexer(config);
|
|
20
|
+
if (program.opts().check) {
|
|
21
|
+
console.info(`@lde/search-indexer ${version}: configuration and schema module “${config.schemaModulePath}” are valid.`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
await pipeline.run();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type DatasetSelector, type DistributionResolver, type Writer } from '@lde/pipeline';
|
|
2
|
+
import type { RootType, SearchDocument } from '@lde/search';
|
|
3
|
+
import type { Client } from 'typesense';
|
|
4
|
+
import type { IndexerConfig } from './config.js';
|
|
5
|
+
/** The registry-backed dataset selection the configuration describes. */
|
|
6
|
+
export declare function datasetSelectorFrom(config: IndexerConfig): DatasetSelector;
|
|
7
|
+
/**
|
|
8
|
+
* The engine-writer factory the configuration describes: an
|
|
9
|
+
* {@link InPlaceRebuild} or {@link BlueGreenRebuild} per root type, its
|
|
10
|
+
* collection name derived from the type – prefixed when the deployment says
|
|
11
|
+
* so, so the read side must be configured with the same prefix.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writerFactoryFrom(client: Client, config: IndexerConfig): (searchType: RootType) => Writer<SearchDocument>;
|
|
14
|
+
/**
|
|
15
|
+
* The distribution resolver the configuration describes: with `QLEVER_IMAGE`
|
|
16
|
+
* set, an {@link ImportResolver} that imports data dumps into a
|
|
17
|
+
* pipeline-controlled QLever sibling container over the mounted Docker
|
|
18
|
+
* socket; without it, `undefined`, leaving the pipeline its endpoint-only
|
|
19
|
+
* default (a bare {@link SparqlDistributionResolver}).
|
|
20
|
+
*/
|
|
21
|
+
export declare function distributionResolverFrom(config: IndexerConfig): DistributionResolver | undefined;
|
|
22
|
+
//# sourceMappingURL=composition.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composition.d.ts","sourceRoot":"","sources":["../src/composition.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,eAAe,EACpB,KAAK,oBAAoB,EACzB,KAAK,MAAM,EACZ,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAO5D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,yEAAyE;AACzE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,CAK1E;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,aAAa,GACpB,CAAC,UAAU,EAAE,QAAQ,KAAK,MAAM,CAAC,cAAc,CAAC,CAWlD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,aAAa,GACpB,oBAAoB,GAAG,SAAS,CAclC"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Internal composition pieces of createSearchIndexer, split out so each can
|
|
2
|
+
// be tested without running a pipeline (Pipeline keeps its wiring private).
|
|
3
|
+
import { Client as RegistryClient } from '@lde/dataset-registry-client';
|
|
4
|
+
import { ImportResolver, RegistrySelector, SparqlDistributionResolver, } from '@lde/pipeline';
|
|
5
|
+
import { BlueGreenRebuild, deriveCollectionName, InPlaceRebuild, } from '@lde/search-typesense';
|
|
6
|
+
import { createQlever } from '@lde/sparql-qlever';
|
|
7
|
+
/** The registry-backed dataset selection the configuration describes. */
|
|
8
|
+
export function datasetSelectorFrom(config) {
|
|
9
|
+
return new RegistrySelector({
|
|
10
|
+
registry: new RegistryClient(config.registryEndpoint),
|
|
11
|
+
criteria: config.datasetCriteria,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The engine-writer factory the configuration describes: an
|
|
16
|
+
* {@link InPlaceRebuild} or {@link BlueGreenRebuild} per root type, its
|
|
17
|
+
* collection name derived from the type – prefixed when the deployment says
|
|
18
|
+
* so, so the read side must be configured with the same prefix.
|
|
19
|
+
*/
|
|
20
|
+
export function writerFactoryFrom(client, config) {
|
|
21
|
+
return (searchType) => {
|
|
22
|
+
const options = config.collectionPrefix
|
|
23
|
+
? {
|
|
24
|
+
name: `${config.collectionPrefix}${deriveCollectionName(searchType)}`,
|
|
25
|
+
}
|
|
26
|
+
: {};
|
|
27
|
+
return config.rebuildMode === 'blue-green'
|
|
28
|
+
? new BlueGreenRebuild(client, searchType, options)
|
|
29
|
+
: new InPlaceRebuild(client, searchType, options);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The distribution resolver the configuration describes: with `QLEVER_IMAGE`
|
|
34
|
+
* set, an {@link ImportResolver} that imports data dumps into a
|
|
35
|
+
* pipeline-controlled QLever sibling container over the mounted Docker
|
|
36
|
+
* socket; without it, `undefined`, leaving the pipeline its endpoint-only
|
|
37
|
+
* default (a bare {@link SparqlDistributionResolver}).
|
|
38
|
+
*/
|
|
39
|
+
export function distributionResolverFrom(config) {
|
|
40
|
+
if (!config.qlever) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const { importer, server } = createQlever({
|
|
44
|
+
mode: 'docker',
|
|
45
|
+
image: config.qlever.image,
|
|
46
|
+
dataDir: config.qlever.dataDir,
|
|
47
|
+
});
|
|
48
|
+
return new ImportResolver(new SparqlDistributionResolver(), {
|
|
49
|
+
importer,
|
|
50
|
+
server,
|
|
51
|
+
strategy: config.qlever.strategy,
|
|
52
|
+
});
|
|
53
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SearchCriteria } from '@lde/dataset-registry-client';
|
|
2
|
+
/**
|
|
3
|
+
* The indexer’s complete runtime configuration, read from environment
|
|
4
|
+
* variables ({@link configFromEnvironment}) – the contract of the Docker
|
|
5
|
+
* image. Everything schema-shaped lives in the mounted schema module instead,
|
|
6
|
+
* the same file the served-API image mounts.
|
|
7
|
+
*/
|
|
8
|
+
export interface IndexerConfig {
|
|
9
|
+
/** Path of the mounted schema-declaration module (`SCHEMA_MODULE`). */
|
|
10
|
+
readonly schemaModulePath: string;
|
|
11
|
+
/** SPARQL endpoint of the DCAT dataset registry the selection queries
|
|
12
|
+
* (`REGISTRY_ENDPOINT`). */
|
|
13
|
+
readonly registryEndpoint: URL;
|
|
14
|
+
/** Dataset selection within the registry: explicit IRIs (`DATASETS`),
|
|
15
|
+
* search criteria (`DATASET_CRITERIA`), or every dataset when neither is
|
|
16
|
+
* set. */
|
|
17
|
+
readonly datasetCriteria: SearchCriteria;
|
|
18
|
+
/** Where the Typesense engine writes (`TYPESENSE_*`). */
|
|
19
|
+
readonly typesense: TypesenseConnection;
|
|
20
|
+
/** How each type’s collection is rebuilt (`REBUILD_MODE`): update the live
|
|
21
|
+
* collection in place, or build a fresh one and swap on commit. */
|
|
22
|
+
readonly rebuildMode: 'in-place' | 'blue-green';
|
|
23
|
+
/** Prefix prepended to every derived collection name
|
|
24
|
+
* (`COLLECTION_PREFIX`), e.g. for a shared multi-tenant engine. */
|
|
25
|
+
readonly collectionPrefix?: string;
|
|
26
|
+
/** Per-dataset processing memory: skip unchanged datasets
|
|
27
|
+
* (`PROVENANCE_FILE` + `PIPELINE_VERSION`). */
|
|
28
|
+
readonly provenance?: ProvenanceConfig;
|
|
29
|
+
/** The QLever import path (`QLEVER_IMAGE` + `IMPORT_STRATEGY` +
|
|
30
|
+
* `DATA_DIR`): import data dumps into a pipeline-controlled QLever
|
|
31
|
+
* container instead of relying on live SPARQL endpoints alone. */
|
|
32
|
+
readonly qlever?: QleverConfig;
|
|
33
|
+
}
|
|
34
|
+
/** Connection details of the Typesense node the indexer writes to. */
|
|
35
|
+
export interface TypesenseConnection {
|
|
36
|
+
readonly host: string;
|
|
37
|
+
readonly port: number;
|
|
38
|
+
readonly protocol: 'http' | 'https';
|
|
39
|
+
/** An admin key: the indexer creates, writes and swaps collections. */
|
|
40
|
+
readonly apiKey: string;
|
|
41
|
+
}
|
|
42
|
+
/** File-backed provenance: both halves required, so a skip-enabled pipeline
|
|
43
|
+
* can never run without the version that keys its skip decisions. */
|
|
44
|
+
export interface ProvenanceConfig {
|
|
45
|
+
/** Path of the JSON provenance file; must sit on a durable volume. */
|
|
46
|
+
readonly path: string;
|
|
47
|
+
/** Consumer-declared version of the indexer’s output-affecting logic. */
|
|
48
|
+
readonly pipelineVersion: string;
|
|
49
|
+
}
|
|
50
|
+
/** The pipeline-controlled QLever sibling container (Docker socket mode). */
|
|
51
|
+
export interface QleverConfig {
|
|
52
|
+
/** QLever Docker image to run, e.g. `adfreiburg/qlever:latest`. */
|
|
53
|
+
readonly image: string;
|
|
54
|
+
/** When to import a data dump instead of querying a live endpoint. */
|
|
55
|
+
readonly strategy: 'sparql' | 'sparqlWithImportFallback' | 'import';
|
|
56
|
+
/** Directory for downloaded dumps and QLever index caches. */
|
|
57
|
+
readonly dataDir: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the {@link IndexerConfig} from environment variables. Reports **all**
|
|
61
|
+
* problems in one error – a misconfigured deployment learns everything
|
|
62
|
+
* wrong with it from a single boot attempt, not one variable per crash loop.
|
|
63
|
+
*/
|
|
64
|
+
export declare function configFromEnvironment(environment: Readonly<Record<string, string | undefined>>): IndexerConfig;
|
|
65
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAEnE;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;iCAC6B;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,GAAG,CAAC;IAC/B;;eAEW;IACX,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC;IACzC,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,mBAAmB,CAAC;IACxC;wEACoE;IACpE,QAAQ,CAAC,WAAW,EAAE,UAAU,GAAG,YAAY,CAAC;IAChD;wEACoE;IACpE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC;oDACgD;IAChD,QAAQ,CAAC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IACvC;;uEAEmE;IACnE,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;CAChC;AAED,sEAAsE;AACtE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IACpC,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;sEACsE;AACtE,MAAM,WAAW,gBAAgB;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,0BAA0B,GAAG,QAAQ,CAAC;IACpE,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAQD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,GACxD,aAAa,CAoFf"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const IMPORT_STRATEGIES = [
|
|
2
|
+
'sparql',
|
|
3
|
+
'sparqlWithImportFallback',
|
|
4
|
+
'import',
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* Read the {@link IndexerConfig} from environment variables. Reports **all**
|
|
8
|
+
* problems in one error – a misconfigured deployment learns everything
|
|
9
|
+
* wrong with it from a single boot attempt, not one variable per crash loop.
|
|
10
|
+
*/
|
|
11
|
+
export function configFromEnvironment(environment) {
|
|
12
|
+
const problems = [];
|
|
13
|
+
const required = (name) => {
|
|
14
|
+
const value = environment[name];
|
|
15
|
+
if (value === undefined || value.length === 0) {
|
|
16
|
+
problems.push(`${name} is required`);
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
const integer = (name, fallback) => {
|
|
22
|
+
const value = environment[name];
|
|
23
|
+
if (value === undefined || value.length === 0) {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
28
|
+
problems.push(`${name} must be a non-negative integer, got “${value}”`);
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
};
|
|
33
|
+
const url = (name, value) => {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// An empty value is already reported as missing by required().
|
|
39
|
+
if (value.length > 0) {
|
|
40
|
+
problems.push(`${name} must be an absolute URL, got “${value}”`);
|
|
41
|
+
}
|
|
42
|
+
return new URL('http://invalid.invalid');
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const registryEndpoint = url('REGISTRY_ENDPOINT', required('REGISTRY_ENDPOINT'));
|
|
46
|
+
const protocol = environment['TYPESENSE_PROTOCOL'] ?? 'http';
|
|
47
|
+
if (protocol !== 'http' && protocol !== 'https') {
|
|
48
|
+
problems.push(`TYPESENSE_PROTOCOL must be “http” or “https”, got “${protocol}”`);
|
|
49
|
+
}
|
|
50
|
+
const rebuildMode = environment['REBUILD_MODE'] ?? 'in-place';
|
|
51
|
+
if (rebuildMode !== 'in-place' && rebuildMode !== 'blue-green') {
|
|
52
|
+
problems.push(`REBUILD_MODE must be “in-place” or “blue-green”, got “${rebuildMode}”`);
|
|
53
|
+
}
|
|
54
|
+
const datasetCriteria = criteriaFromEnvironment(environment, problems);
|
|
55
|
+
const provenance = provenanceFromEnvironment(environment, rebuildMode, problems);
|
|
56
|
+
const qlever = qleverFromEnvironment(environment, problems);
|
|
57
|
+
const config = {
|
|
58
|
+
schemaModulePath: environment['SCHEMA_MODULE'] ?? '/config/search-schema.mjs',
|
|
59
|
+
registryEndpoint,
|
|
60
|
+
datasetCriteria,
|
|
61
|
+
typesense: {
|
|
62
|
+
host: required('TYPESENSE_HOST'),
|
|
63
|
+
port: integer('TYPESENSE_PORT', 8108),
|
|
64
|
+
protocol: protocol,
|
|
65
|
+
apiKey: required('TYPESENSE_API_KEY'),
|
|
66
|
+
},
|
|
67
|
+
rebuildMode: rebuildMode,
|
|
68
|
+
collectionPrefix: environment['COLLECTION_PREFIX'],
|
|
69
|
+
provenance,
|
|
70
|
+
qlever,
|
|
71
|
+
};
|
|
72
|
+
if (problems.length > 0) {
|
|
73
|
+
throw new Error(`Invalid configuration:\n${problems.map((problem) => `- ${problem}`).join('\n')}`);
|
|
74
|
+
}
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
/** `DATASETS` (whitespace-separated IRIs) or `DATASET_CRITERIA` (a JSON
|
|
78
|
+
* object) – never both; neither selects the whole registry. */
|
|
79
|
+
function criteriaFromEnvironment(environment, problems) {
|
|
80
|
+
const datasets = environment['DATASETS'];
|
|
81
|
+
const criteriaJson = environment['DATASET_CRITERIA'];
|
|
82
|
+
if (datasets && criteriaJson) {
|
|
83
|
+
problems.push('DATASETS and DATASET_CRITERIA are mutually exclusive; set at most one');
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
if (datasets) {
|
|
87
|
+
const iris = datasets.split(/[\s,]+/).filter((iri) => iri.length > 0);
|
|
88
|
+
for (const iri of iris) {
|
|
89
|
+
try {
|
|
90
|
+
new URL(iri);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
problems.push(`DATASETS contains “${iri}”, which is not an IRI`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { $id: iris };
|
|
97
|
+
}
|
|
98
|
+
if (criteriaJson) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(criteriaJson);
|
|
101
|
+
if (typeof parsed !== 'object' ||
|
|
102
|
+
parsed === null ||
|
|
103
|
+
Array.isArray(parsed)) {
|
|
104
|
+
problems.push('DATASET_CRITERIA must be a JSON object');
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
problems.push('DATASET_CRITERIA must be valid JSON');
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
function provenanceFromEnvironment(environment, rebuildMode, problems) {
|
|
117
|
+
const path = environment['PROVENANCE_FILE'];
|
|
118
|
+
const pipelineVersion = environment['PIPELINE_VERSION'];
|
|
119
|
+
if (!path && !pipelineVersion) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!path || !pipelineVersion) {
|
|
123
|
+
problems.push('PROVENANCE_FILE and PIPELINE_VERSION must be set together: the version keys the skip decisions the file remembers');
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (rebuildMode === 'blue-green') {
|
|
127
|
+
problems.push('PROVENANCE_FILE cannot be combined with REBUILD_MODE=blue-green: a skipped dataset would be missing from the fresh collection the swap makes live');
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return { path, pipelineVersion };
|
|
131
|
+
}
|
|
132
|
+
function qleverFromEnvironment(environment, problems) {
|
|
133
|
+
const image = environment['QLEVER_IMAGE'];
|
|
134
|
+
const strategy = environment['IMPORT_STRATEGY'];
|
|
135
|
+
if (!image) {
|
|
136
|
+
if (strategy) {
|
|
137
|
+
problems.push('IMPORT_STRATEGY requires QLEVER_IMAGE: without an import engine there is nothing to import into');
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
if (strategy !== undefined &&
|
|
142
|
+
!IMPORT_STRATEGIES.includes(strategy)) {
|
|
143
|
+
problems.push(`IMPORT_STRATEGY must be one of ${IMPORT_STRATEGIES.map((name) => `“${name}”`).join(', ')}, got “${strategy}”`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
image,
|
|
147
|
+
strategy: (strategy ?? 'sparql'),
|
|
148
|
+
dataDir: environment['DATA_DIR'] ?? '/data',
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,YAAY,EACV,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,mBAAmB,GACpB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The bootable search indexer: the composition layer binding the
|
|
2
|
+
// engine-agnostic @lde/search-pipeline indexer to Typesense rebuild writers,
|
|
3
|
+
// a mounted schema-declaration module and environment config. The
|
|
4
|
+
// `search-indexer` bin (src/cli.ts) and the Docker image are thin wrappers
|
|
5
|
+
// over these.
|
|
6
|
+
export { createSearchIndexer } from './indexer.js';
|
|
7
|
+
export { configFromEnvironment } from './config.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Pipeline } from '@lde/pipeline';
|
|
2
|
+
import { type TypedSearchDocument } from '@lde/search-pipeline';
|
|
3
|
+
import type { IndexerConfig } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Compose the ready-to-run search indexer from an {@link IndexerConfig}: load
|
|
6
|
+
* and validate the mounted schema module (the same file the served-API image
|
|
7
|
+
* mounts), select datasets from the registry, bind the Typesense rebuild
|
|
8
|
+
* writers, and wire the optional QLever import path and provenance store into
|
|
9
|
+
* `searchIndexerPipeline`. Every misconfiguration – an invalid schema, an
|
|
10
|
+
* underivable collection name – throws here, at boot, never mid-run.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createSearchIndexer(config: IndexerConfig): Promise<Pipeline<TypedSearchDocument>>;
|
|
13
|
+
//# sourceMappingURL=indexer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"indexer.d.ts","sourceRoot":"","sources":["../src/indexer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGnE,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAC;AAO9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC,CAuBxC"}
|
package/dist/indexer.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { FileProvenanceStore } from '@lde/pipeline';
|
|
2
|
+
import { ConsoleReporter } from '@lde/pipeline-console-reporter';
|
|
3
|
+
import { loadSchemaModule } from '@lde/search/module';
|
|
4
|
+
import { searchIndexerPipeline, } from '@lde/search-pipeline';
|
|
5
|
+
import { Client } from 'typesense';
|
|
6
|
+
import { datasetSelectorFrom, distributionResolverFrom, writerFactoryFrom, } from './composition.js';
|
|
7
|
+
/**
|
|
8
|
+
* Compose the ready-to-run search indexer from an {@link IndexerConfig}: load
|
|
9
|
+
* and validate the mounted schema module (the same file the served-API image
|
|
10
|
+
* mounts), select datasets from the registry, bind the Typesense rebuild
|
|
11
|
+
* writers, and wire the optional QLever import path and provenance store into
|
|
12
|
+
* `searchIndexerPipeline`. Every misconfiguration – an invalid schema, an
|
|
13
|
+
* underivable collection name – throws here, at boot, never mid-run.
|
|
14
|
+
*/
|
|
15
|
+
export async function createSearchIndexer(config) {
|
|
16
|
+
const { schema } = await loadSchemaModule(config.schemaModulePath);
|
|
17
|
+
const client = new Client({
|
|
18
|
+
nodes: [
|
|
19
|
+
{
|
|
20
|
+
host: config.typesense.host,
|
|
21
|
+
port: config.typesense.port,
|
|
22
|
+
protocol: config.typesense.protocol,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
apiKey: config.typesense.apiKey,
|
|
26
|
+
});
|
|
27
|
+
return searchIndexerPipeline({
|
|
28
|
+
schema,
|
|
29
|
+
datasets: datasetSelectorFrom(config),
|
|
30
|
+
distributionResolver: distributionResolverFrom(config),
|
|
31
|
+
writerFor: writerFactoryFrom(client, config),
|
|
32
|
+
provenanceStore: config.provenance
|
|
33
|
+
? new FileProvenanceStore({ path: config.provenance.path })
|
|
34
|
+
: undefined,
|
|
35
|
+
pipelineVersion: config.provenance?.pipelineVersion,
|
|
36
|
+
reporter: new ConsoleReporter(),
|
|
37
|
+
});
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lde/search-indexer",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The @lde/search-pipeline indexer as a bootable process and prebuilt Docker image: loads a mounted schema-declaration module, dataset selection, Typesense connection and rebuild mode from the environment, then runs the object-grain searchIndexerPipeline once to completion. The composition layer that binds the engine-agnostic @lde/search-pipeline stages to @lde/search-typesense – the write-path counterpart of @lde/search-api-server.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "git+https://github.com/ldelements/lde.git",
|
|
7
|
+
"directory": "packages/search-indexer"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"development": "./src/index.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"bin": {
|
|
24
|
+
"search-indexer": "dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"!**/*.tsbuildinfo"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@lde/dataset-registry-client": "^0.9.1",
|
|
32
|
+
"@lde/pipeline": "^0.35.3",
|
|
33
|
+
"@lde/pipeline-console-reporter": "^0.26.3",
|
|
34
|
+
"@lde/search": "^0.12.2",
|
|
35
|
+
"@lde/search-pipeline": "^0.12.4",
|
|
36
|
+
"@lde/search-typesense": "^0.15.4",
|
|
37
|
+
"@lde/sparql-qlever": "^0.15.2",
|
|
38
|
+
"commander": "^15.0.0",
|
|
39
|
+
"tslib": "^2.3.0",
|
|
40
|
+
"typesense": "^3.0.6"
|
|
41
|
+
},
|
|
42
|
+
"nx": {
|
|
43
|
+
"targets": {
|
|
44
|
+
"docker:stage": {
|
|
45
|
+
"options": {
|
|
46
|
+
"cwd": "{projectRoot}",
|
|
47
|
+
"command": "rm -rf dist-docker && mkdir dist-docker && cp package.json dist-docker/ && cp -R dist dist-docker/dist"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"copy-workspace-modules": {},
|
|
51
|
+
"prune-lockfile": {},
|
|
52
|
+
"docker:lockfile": {},
|
|
53
|
+
"docker:smoke": {
|
|
54
|
+
"executor": "nx:run-commands",
|
|
55
|
+
"dependsOn": [
|
|
56
|
+
"docker:build"
|
|
57
|
+
],
|
|
58
|
+
"cache": false,
|
|
59
|
+
"options": {
|
|
60
|
+
"command": "./tools/docker-smoke-indexer.sh packages-search-indexer"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|