From 888925419fc20256c30f4ea5736570105e08d65e Mon Sep 17 00:00:00 2001 From: David de Boer Date: Thu, 23 Jul 2026 15:57:43 +0200 Subject: [PATCH 1/2] feat(search): reject an unknown field kind at declaration time - validateSearchType silently accepted a kind outside the FieldKind union: every kind-dependent rule passed vacuously, deferring the failure to whatever consumer read the declaration first - guards the plain-JS declaration path (a mounted schema module, a SHACL generator) that the runtime validation exists for - adds the 'unknown-kind' issue reason --- packages/search/src/schema.ts | 7 +++++++ packages/search/test/schema.test.ts | 8 ++++++++ packages/search/vite.config.ts | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/search/src/schema.ts b/packages/search/src/schema.ts index bfbae2c4..67670657 100644 --- a/packages/search/src/schema.ts +++ b/packages/search/src/schema.ts @@ -581,6 +581,7 @@ export interface SearchTypeIssue { readonly reason: | 'duplicate-field-name' | 'invalid-field-name' + | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' @@ -668,6 +669,12 @@ export function validateSearchType( if (!FIELD_NAME_PATTERN.test(field.name)) { issue('invalid-field-name'); } + // Every kind-dependent rule below would silently pass for a kind outside + // the union, so a typo’d kind in a plain-JS declaration must fail here. + if (!Object.hasOwn(OPERATOR_BY_KIND, field.kind)) { + issue('unknown-kind'); + continue; + } if (field.kind === 'reference') { if (field.output === true && field.ref === undefined) { issue('missing-ref'); diff --git a/packages/search/test/schema.test.ts b/packages/search/test/schema.test.ts index 735917cf..35ec6586 100644 --- a/packages/search/test/schema.test.ts +++ b/packages/search/test/schema.test.ts @@ -331,6 +331,14 @@ describe('validateSearchType', () => { ]); }); + it('rejects an unknown field kind', () => { + // A typo’d kind in a plain-JS declaration must fail at declaration time – + // every kind-dependent rule would silently pass for it otherwise. + expect( + validateSearchType(typeWith({ name: 'broken', kind: 'no-such-kind' })), + ).toEqual([{ field: 'broken', reason: 'unknown-kind' }]); + }); + it('rejects a field name carrying a regex metacharacter', () => { // The name is interpolated raw into the display RE2 pattern, so a // metacharacter would over-match or break the collection schema. diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index 667a216e..59459de5 100644 --- a/packages/search/vite.config.ts +++ b/packages/search/vite.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( thresholds: { functions: 100, lines: 100, - branches: 98.87, + branches: 98.88, statements: 100, }, }, From 41e2650cba898a719e6ae3ce7ac2843a72749cda Mon Sep 17 00:00:00 2001 From: David de Boer Date: Thu, 23 Jul 2026 15:58:06 +0200 Subject: [PATCH 2/2] feat(search-api-server): serve the search API as a bootable process and Docker image - new composition package binding the engine-agnostic search-api-graphql handler to a Typesense engine: environment config, a mounted plain-data schema-declaration module (validated at boot), and a node:http server answering /graphql, /health and a root redirect - the search-api-server bin boots from environment variables alone and reports every configuration problem in one error - Dockerfile installs the published package from npm, so each image corresponds to a provenance-attested version; the Docker workflow builds and pushes ghcr.io/ldelements/search-api-server on each release tag, waiting for the npm publish the release run does after tagging - recorded as ADR 15 --- .github/workflows/docker.yml | 61 +++++++++ README.md | 7 ++ ...pi-as-a-docker-image-installed-from-npm.md | 66 ++++++++++ package-lock.json | 56 ++++++--- packages/search-api-server/Dockerfile | 16 +++ packages/search-api-server/README.md | 118 ++++++++++++++++++ packages/search-api-server/eslint.config.mjs | 22 ++++ packages/search-api-server/package.json | 38 ++++++ packages/search-api-server/src/cli.ts | 26 ++++ packages/search-api-server/src/config.ts | 99 +++++++++++++++ packages/search-api-server/src/index.ts | 10 ++ .../search-api-server/src/schema-module.ts | 88 +++++++++++++ packages/search-api-server/src/server.ts | 91 ++++++++++++++ .../search-api-server/test/config.test.ts | 80 ++++++++++++ .../test/fixtures/invalid-declaration.mjs | 7 ++ .../test/fixtures/not-an-array.mjs | 1 + .../test/fixtures/search-schema.mjs | 29 +++++ .../test/schema-module.test.ts | 36 ++++++ .../search-api-server/test/server.test.ts | 89 +++++++++++++ packages/search-api-server/tsconfig.json | 16 +++ packages/search-api-server/tsconfig.lib.json | 37 ++++++ packages/search-api-server/tsconfig.spec.json | 26 ++++ packages/search-api-server/vite.config.ts | 30 +++++ tsconfig.json | 3 + 24 files changed, 1034 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 docs/decisions/0015-ship-the-served-search-api-as-a-docker-image-installed-from-npm.md create mode 100644 packages/search-api-server/Dockerfile create mode 100644 packages/search-api-server/README.md create mode 100644 packages/search-api-server/eslint.config.mjs create mode 100644 packages/search-api-server/package.json create mode 100644 packages/search-api-server/src/cli.ts create mode 100644 packages/search-api-server/src/config.ts create mode 100644 packages/search-api-server/src/index.ts create mode 100644 packages/search-api-server/src/schema-module.ts create mode 100644 packages/search-api-server/src/server.ts create mode 100644 packages/search-api-server/test/config.test.ts create mode 100644 packages/search-api-server/test/fixtures/invalid-declaration.mjs create mode 100644 packages/search-api-server/test/fixtures/not-an-array.mjs create mode 100644 packages/search-api-server/test/fixtures/search-schema.mjs create mode 100644 packages/search-api-server/test/schema-module.test.ts create mode 100644 packages/search-api-server/test/server.test.ts create mode 100644 packages/search-api-server/tsconfig.json create mode 100644 packages/search-api-server/tsconfig.lib.json create mode 100644 packages/search-api-server/tsconfig.spec.json create mode 100644 packages/search-api-server/vite.config.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..b1db1a45 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,61 @@ +name: Docker + +on: + push: + tags: + - '@lde/search-api-server@*' + workflow_dispatch: + inputs: + version: + description: 'Published package version to build the image from' + required: true + +permissions: + contents: read + packages: write + +jobs: + publish-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "version=${GITHUB_REF_NAME##*@}" >> "$GITHUB_OUTPUT" + fi + + # The release run pushes the tag before it publishes to npm, so the + # version this workflow was triggered for may not be installable yet. + - name: Wait for the npm publish + run: | + for attempt in $(seq 1 30); do + if npm view "@lde/search-api-server@${{ steps.version.outputs.version }}" version > /dev/null 2>&1; then + exit 0 + fi + sleep 10 + done + echo "@lde/search-api-server@${{ steps.version.outputs.version }} never appeared on npm" >&2 + exit 1 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: packages/search-api-server + build-args: | + VERSION=${{ steps.version.outputs.version }} + push: true + tags: | + ghcr.io/ldelements/search-api-server:${{ steps.version.outputs.version }} + ghcr.io/ldelements/search-api-server:latest diff --git a/README.md b/README.md index 943258d4..262e9ffb 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,11 @@ await pipeline.run(); npm Engine- and domain-agnostic GraphQL surface for search: builds an executable GraphQL schema from a SearchSchema at runtime and serves it as a framework-agnostic fetch handler with a self-contained playground + + @lde/search-api-server + npm + The served search API as a bootable process and prebuilt Docker image: mounts a schema-declaration module, binds the GraphQL handler to a Typesense engine, and serves /graphql plus /health from environment config + @lde/search-pipeline npm @@ -241,6 +246,8 @@ graph TD docgen search --> text-normalization search-api-graphql --> search + search-api-server --> search-api-graphql + search-api-server --> search-typesense search-typesense --> search search-typesense --> text-normalization search-typesense --> pipeline diff --git a/docs/decisions/0015-ship-the-served-search-api-as-a-docker-image-installed-from-npm.md b/docs/decisions/0015-ship-the-served-search-api-as-a-docker-image-installed-from-npm.md new file mode 100644 index 00000000..27a41717 --- /dev/null +++ b/docs/decisions/0015-ship-the-served-search-api-as-a-docker-image-installed-from-npm.md @@ -0,0 +1,66 @@ +# 15. Ship the served search API as a Docker image installed from npm + +Date: 2026-07-23 + +## Status + +Accepted + +Extends [ADR 14 (Serve the search GraphQL API with graphql-yoga)](./0014-serve-the-search-graphql-api-with-graphql-yoga.md). +Implements the image layer (layer 3) of +[#600](https://github.com/ldelements/lde/issues/600). + +## Context + +The `createSearchGraphQLHandler()` fetch handler (ADR 14) still requires a JS +host to mount it. Turnkey, non-JS and ops-driven deployments – and the +dedicated search-API pod topology #600 recommends – need a prebuilt image that +boots from configuration alone. + +Three constraints shaped the design: + +1. `@lde/search-api-graphql` deliberately names neither the domain nor the + engine, so a bootable server that binds Typesense cannot live there. +2. A mounted ES module cannot use bare imports (`import '@lde/search'` does not + resolve from a mounted path), so the schema mount cannot be authored against + the library. The SHACL + `search:` source #600 assumed does not exist yet – + schema generation is [#495](https://github.com/ldelements/lde/issues/495)’s + still-open scope. +3. Docker-building the Nx workspace inside the image duplicates the release + pipeline and produces images that do not correspond to published, versioned + artifacts. + +## Decision + +- **A separate composition package, `@lde/search-api-server`**: environment + config, schema-module loading, and a `node:http` server around the ADR 14 + handler bound to `createTypesenseSearchEngine`. The engine-agnostic handler + package stays engine-agnostic. +- **The schema mounts as a plain-data declaration module**: a `.mjs` file + default-exporting `SearchType` declarations, validated at boot by + `searchSchema()` – the exact “declarations built outside TypeScript” + path the runtime validation exists for. Optional functions (`derive`, + `transform`) remain expressible; a serving process never calls them. When + #495 delivers the SHACL + `search:` generator, mounted SHACL becomes a + second source for the same schema. +- **The image installs the published package from npm** (`npm install +@lde/search-api-server@` in a `node:alpine` base) instead of + building the workspace. Each image corresponds one-to-one to a published, + provenance-attested npm version, and the Dockerfile stays a few lines. +- **Publishing is tag-triggered**: the release run’s + `@lde/search-api-server@` tag triggers the Docker workflow, which + waits for the version to appear on npm (the release tags before it + publishes), then pushes `ghcr.io/ldelements/search-api-server:` and + `:latest`. + +## Consequences + +- Ops deploys get a turnkey `/graphql` + playground from a schema mount and + environment variables; JS hosts keep mounting the handler directly. +- The image cannot serve custom-code GraphQL fields – by design (#600): such + consumers use the handler, or build `FROM` this image. +- Image availability trails the npm publish by up to the workflow’s wait; a + missed publish (e.g. the manual first-version bootstrap) surfaces as a failed + Docker run to re-trigger via `workflow_dispatch`. +- The first image requires the package’s manual npm bootstrap + (see [Releasing a new package](../../CLAUDE.md#releasing-a-new-package)). diff --git a/package-lock.json b/package-lock.json index d2389389..31da4c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20436,6 +20436,10 @@ "resolved": "packages/search-api-graphql", "link": true }, + "node_modules/@lde/search-api-server": { + "resolved": "packages/search-api-server", + "link": true + }, "node_modules/@lde/search-pipeline": { "resolved": "packages/search-pipeline", "link": true @@ -35793,7 +35797,7 @@ }, "packages/dataset-registry-client": { "name": "@lde/dataset-registry-client", - "version": "0.8.6", + "version": "0.9.0", "license": "MIT", "dependencies": { "@lde/dataset": "^0.7.8", @@ -37240,7 +37244,7 @@ }, "packages/fastify-rdf": { "name": "@lde/fastify-rdf", - "version": "0.4.8", + "version": "0.5.0", "license": "MIT", "dependencies": { "@fastify/accepts": "^5.0.0", @@ -37959,11 +37963,11 @@ }, "packages/pipeline": { "name": "@lde/pipeline", - "version": "0.34.4", + "version": "0.35.0", "license": "MIT", "dependencies": { "@lde/dataset": "^0.7.8", - "@lde/dataset-registry-client": "^0.8.6", + "@lde/dataset-registry-client": "^0.9.0", "@lde/distribution-health": "^0.2.7", "@lde/distribution-probe": "^0.2.6", "@lde/sparql-importer": "^0.6.7", @@ -37985,7 +37989,7 @@ }, "packages/pipeline-console-reporter": { "name": "@lde/pipeline-console-reporter", - "version": "0.25.4", + "version": "0.26.0", "license": "MIT", "dependencies": { "chalk": "^5.4.1", @@ -37996,7 +38000,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/pipeline-console-reporter/node_modules/ansi-regex": { @@ -38176,7 +38180,7 @@ }, "packages/pipeline-shacl-sampler": { "name": "@lde/pipeline-shacl-sampler", - "version": "0.8.4", + "version": "0.9.0", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -38187,7 +38191,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/pipeline-shacl-sampler/node_modules/n3": { @@ -38205,7 +38209,7 @@ }, "packages/pipeline-shacl-validator": { "name": "@lde/pipeline-shacl-validator", - "version": "0.17.4", + "version": "0.18.0", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -38219,7 +38223,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/pipeline-shacl-validator/node_modules/n3": { @@ -38238,7 +38242,7 @@ }, "packages/pipeline-void": { "name": "@lde/pipeline-void", - "version": "0.32.4", + "version": "0.33.0", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -38249,7 +38253,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/pipeline-void/node_modules/n3": { @@ -38305,7 +38309,7 @@ }, "packages/search-api-graphql": { "name": "@lde/search-api-graphql", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "dependencies": { "@escape.tech/graphql-armor-cost-limit": "^2.4.3", @@ -38319,9 +38323,25 @@ "tslib": "^2.3.0" } }, + "packages/search-api-server": { + "name": "@lde/search-api-server", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@lde/search": "^0.12.0", + "@lde/search-api-graphql": "^0.12.0", + "@lde/search-typesense": "^0.15.0", + "@whatwg-node/server": "^0.11.0", + "tslib": "^2.3.0", + "typesense": "^3.0.6" + }, + "bin": { + "search-api-server": "dist/cli.js" + } + }, "packages/search-pipeline": { "name": "@lde/search-pipeline", - "version": "0.11.2", + "version": "0.12.0", "license": "MIT", "dependencies": { "@lde/search": "^0.12.0", @@ -38331,7 +38351,7 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@lde/search-typesense": "^0.14.2", + "@lde/search-typesense": "^0.15.0", "@rdfjs/types": "^2.0.1", "n3": "^2.1.1", "testcontainers": "^12.0.3", @@ -38339,7 +38359,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/search-pipeline/node_modules/n3": { @@ -38358,7 +38378,7 @@ }, "packages/search-typesense": { "name": "@lde/search-typesense", - "version": "0.14.2", + "version": "0.15.0", "license": "MIT", "dependencies": { "@lde/search": "^0.12.0", @@ -38371,7 +38391,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.8", - "@lde/pipeline": "^0.34.4" + "@lde/pipeline": "^0.35.0" } }, "packages/search/node_modules/n3": { diff --git a/packages/search-api-server/Dockerfile b/packages/search-api-server/Dockerfile new file mode 100644 index 00000000..83dc494c --- /dev/null +++ b/packages/search-api-server/Dockerfile @@ -0,0 +1,16 @@ +# The prebuilt served search API (https://github.com/ldelements/lde/issues/600, +# layer 3): installs the published @lde/search-api-server from npm rather than +# building the workspace, so the image is small and reproducible from public, +# provenance-attested artifacts. Built by .github/workflows/docker.yml on each +# release tag of the package. +FROM node:24-alpine + +ARG VERSION=latest +ENV NODE_ENV=production +RUN npm install --global @lde/search-api-server@${VERSION} + +USER node +EXPOSE 4000 +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --quiet --output-document=- http://127.0.0.1:${PORT:-4000}/health || exit 1 +CMD ["search-api-server"] diff --git a/packages/search-api-server/README.md b/packages/search-api-server/README.md new file mode 100644 index 00000000..de4d3da9 --- /dev/null +++ b/packages/search-api-server/README.md @@ -0,0 +1,118 @@ +# @lde/search-api-server + +The served [@lde/search](../search) API as a bootable process and prebuilt +Docker image: mount a schema-declaration module, point it at Typesense, and it +serves `/graphql` – POST execution, the self-contained playground, the SDL – +plus `/health`, with CORS and depth/cost limits on by default. + +This is the composition layer +([#600](https://github.com/ldelements/lde/issues/600), layer 3) that binds the +engine-agnostic [`@lde/search-api-graphql`](../search-api-graphql) handler +(layer 2) to the [`@lde/search-typesense`](../search-typesense) engine. Use it +for turnkey, non-JS and ops-driven deployments; a JS host that wants custom +GraphQL fields mounts the handler itself instead (or builds `FROM` this image). + +## Run + +```sh +docker run --publish 4000:4000 \ + --volume ./search-schema.mjs:/config/search-schema.mjs:ro \ + --env TYPESENSE_HOST=typesense.internal \ + --env TYPESENSE_API_KEY=search-only-key \ + ghcr.io/ldelements/search-api-server +``` + +Or without Docker (the same environment variables apply): + +```sh +npx @lde/search-api-server +``` + +## The schema module + +The mounted module default-exports the deployment’s search type declarations as +**plain data** – it must not import `@lde/search` (bare specifiers do not +resolve from a mounted file), and does not need to: the server validates the +declarations at boot, exactly as it would a SHACL generator’s output. Optional +functions (`derive`, `transform`) are allowed; they are projection-time +declarations a serving process never calls. If you must import, bundle the +module (e.g. esbuild) before mounting it. + +```js +// search-schema.mjs +export default [ + { + name: 'Dataset', + class: 'http://www.w3.org/ns/dcat#Dataset', + fields: [ + { + name: 'title', + kind: 'text', + locales: ['nl', 'en'], + output: true, + searchable: { weight: 5 }, + }, + { + name: 'keyword', + kind: 'keyword', + array: true, + facetable: true, + output: true, + }, + ], + }, +]; + +// Optional: forwarded to buildGraphQLSchema (per-type options, maxPerPage, …). +export const schemaOptions = { maxPerPage: 50 }; + +// Optional: forwarded to createTypesenseSearchEngine (collection overrides, …). +export const engineOptions = {}; +``` + +Once the SHACL + `search:` generator lands +([#495](https://github.com/ldelements/lde/issues/495)), mounted SHACL becomes +an additional source for the same schema. + +## Configuration + +| Variable | Default | Meaning | +| -------------------- | --------------------------- | -------------------------------------------------- | +| `SCHEMA_MODULE` | `/config/search-schema.mjs` | Path of the mounted schema-declaration module | +| `PORT` | `4000` | TCP port the server binds | +| `GRAPHQL_ENDPOINT` | `/graphql` | Path serving GraphQL, the playground and the SDL | +| `PLAYGROUND` | `true` | Serve the playground on GET (`false`/`0` disables) | +| `MAX_DEPTH` | handler default (15) | Query depth cap | +| `MAX_COST` | handler default (5000) | Query cost cap | +| `TYPESENSE_HOST` | **required** | Typesense host | +| `TYPESENSE_PORT` | `8108` | Typesense port | +| `TYPESENSE_PROTOCOL` | `http` | `http` or `https` | +| `TYPESENSE_API_KEY` | **required** | Use a search-only key: the server only ever reads | + +A misconfigured boot reports **all** problems in one error, not one per crash +loop. + +## Endpoints + +- `POST /graphql` – GraphQL execution. +- `GET /graphql` – the self-contained playground (no external CDN, no framing + headers, so a docs site can `