15. Ship the served search API as a Docker image built from the workspace
Date: 2026-07-23
Status
Accepted
Extends ADR 14 (Serve the search GraphQL API with graphql-yoga). Implements the image layer (layer 3) of #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:
@lde/search-api-graphqldeliberately names neither the domain nor the engine, so a bootable server that binds Typesense cannot live there.- 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’s still-open scope. - An image built from the published npm package (the first design of this ADR) couples the Docker build to the npm publish: the build must wait for the registry, the first image is blocked on the new package’s manual Trusted-Publishing bootstrap, and no image can be built – let alone smoke-tested – for unpublished code in a PR.
The Dataset Register went through the same evolution for its app images and settled on building from the workspace, unbundled, after an esbuild-bundled image hid a workspace lib’s transitive dependencies until the container crashed at startup (dataset-register#2128/#2130). We adopt its end state.
Decision
- A separate composition package,
@lde/search-api-server: environment config, schema-module loading, and anode:httpserver around the ADR 14 handler bound tocreateTypesenseSearchEngine. The engine-agnostic handler package stays engine-agnostic. It is npm-published like every other package. - The schema mounts as a plain-data declaration module: a
.mjsfile default-exportingSearchTypedeclarations, validated at boot bysearchSchema()– 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 is built from the workspace’s own outputs, not from npm.
docker:build(inferred by@nx/docker) depends on a staging chain –docker:stage(compiled package),@nx/js:copy-workspace-modules(same-commit builds of the@lde/*dependencies),@nx/js:prune-lockfileplus anpm install --package-lock-onlyrepair step (docker:lockfile, needed because Nx’s lockfile pruning cannot map this workspace’s graph and falls back to the root lockfile) – and the Dockerfile is a plainnpm ci --omit=dev+COPY. No bundling: the image keeps real modules, so a missing dependency fails loudly instead of being silently inlined away. docker:smokegates CI: the built image is booted with a fixture schema and probed over HTTP (/health,/graphql?sdl) innx affected, so both build-time and runtime-only failures (theERR_MODULE_NOT_FOUNDclass) fail the PR, not production.- Publishing is release-triggered but registry-independent: the GitHub Release nx release creates for
@lde/search-api-server@<version>triggers the Docker workflow (a tag trigger can never fire here: a multi-package release pushes more than three tags at once, and GitHub then emits no tag-push events), which checks out that commit, rebuilds and smoke-tests the image, and pushesghcr.io/ldelements/search-api-server:<version>and:latest. Image tags stay aligned with the package’s semver;nx release’s own Docker support (experimental, calendar-versioned, and unable to both npm- and Docker-publish one project) is deliberately not used. - The read-only image omits
@lde/search-typesense’s peers (@lde/pipeline,@lde/dataset;npm ci --legacy-peer-deps): they type the write side only and every runtime import isimport type. The smoke test guards this assumption.
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
FROMthis image. - The Docker build no longer waits on – or can be broken by – the npm publish; the npm package’s one-time manual bootstrap only affects npm consumers, not the image.
- Every affected PR pays for an image build + boot in CI (~15 s), in exchange for catching pruned-image runtime failures before merge.
- The
docker:lockfilerepair step is a workaround for the Nx pruning fallback; when Nx learns to prune this workspace’s lockfile it can be dropped.