Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ jobs:
# - run: npx nx-cloud record -- echo Hello World
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
- run: npx nx affected -t lint test typecheck build

# docker:smoke builds each affected Docker image and boots it, so a
# runtime-only failure (e.g. a dependency missing from the pruned image)
# cannot pass a green build. A separate step, AFTER the test suites:
# image builds are heavy enough to starve timing-sensitive tests when
# they share the runner (its build/stage prerequisites replay from the
# cache of the previous step).
- run: npx nx affected -t docker:smoke
71 changes: 71 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Docker

on:
push:
tags:
- '@lde/search-api-server@*'
- '@lde/search-indexer@*'
workflow_dispatch:
inputs:
ref:
description: 'Tag or ref to build the image from (e.g. @lde/search-api-server@0.1.0)'
required: true

permissions:
contents: read
packages: write

# Release runs tag several packages in quick succession; serialize the image
# pushes so :latest cannot land out of order.
concurrency:
group: docker-publish
cancel-in-progress: false

jobs:
publish-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}

# The image is built from this tagged commit's own workspace outputs
# (nx docker:build stages the compiled package, its workspace
# dependencies and a pruned lockfile), so it needs no npm publish to
# have happened – the release tag alone identifies the package and
# version (e.g. "@lde/search-indexer@0.1.0").
- name: Determine package and version
id: version
# The ref reaches the shell via env, never inline interpolation, so a
# crafted dispatch input cannot inject shell syntax.
env:
REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref_name }}
run: |
package="${REF#@lde/}"
echo "package=${package%@*}" >> "$GITHUB_OUTPUT"
echo "version=${REF##*@}" >> "$GITHUB_OUTPUT"

- uses: actions/setup-node@v7
with:
node-version: lts/*
cache: 'npm'

- run: npm ci

- name: Build and smoke-test the image
run: npx nx run "@lde/${{ steps.version.outputs.package }}:docker:smoke"

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Push the image
run: |
package="${{ steps.version.outputs.package }}"
version="${{ steps.version.outputs.version }}"
docker tag "packages-${package}" "ghcr.io/ldelements/${package}:${version}"
docker tag "packages-${package}" "ghcr.io/ldelements/${package}:latest"
docker push "ghcr.io/ldelements/${package}:${version}"
docker push "ghcr.io/ldelements/${package}:latest"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ vite.base.config.d.ts.map
.claude/settings.local.json
.nx/polygraph
.nx/self-healing
.nx/migrate-runs
.nx/migrate-runs
dist-docker
3 changes: 3 additions & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The Docker staging output contains copies of workspace packages
# (copy-workspace-modules), which must not be detected as Nx projects.
packages/*/dist-docker/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ await pipeline.run();
<td><a href="https://www.npmjs.com/package/@lde/search-api-graphql"><img src="https://img.shields.io/npm/v/@lde/search-api-graphql" alt="npm"></a></td>
<td>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</td>
</tr>
<tr>
<td><a href="packages/search-api-server">@lde/search-api-server</a></td>
<td><a href="https://www.npmjs.com/package/@lde/search-api-server"><img src="https://img.shields.io/npm/v/@lde/search-api-server" alt="npm"></a></td>
<td>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</td>
</tr>
<tr>
<td><a href="packages/search-indexer">@lde/search-indexer</a></td>
<td><a href="https://www.npmjs.com/package/@lde/search-indexer"><img src="https://img.shields.io/npm/v/@lde/search-indexer" alt="npm"></a></td>
<td>The search indexer as a bootable process and prebuilt Docker image: mounts the same schema-declaration module as the API server, selects datasets from a registry, and rebuilds the Typesense collections from environment config</td>
</tr>
<tr>
<td><a href="packages/search-pipeline">@lde/search-pipeline</a></td>
<td><a href="https://www.npmjs.com/package/@lde/search-pipeline"><img src="https://img.shields.io/npm/v/@lde/search-pipeline" alt="npm"></a></td>
Expand Down Expand Up @@ -241,11 +251,17 @@ 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
search-pipeline --> search
search-pipeline --> pipeline
search-indexer --> search-pipeline
search-indexer --> search-typesense
search-indexer --> sparql-qlever
search-indexer --> pipeline-console-reporter
end

subgraph Monitoring
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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)](./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. 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 a `node:http` server around the ADR 14
handler bound to `createTypesenseSearchEngine`. 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 `.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 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-lockfile`
plus a `npm install --package-lock-only` repair 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 plain
`npm ci --omit=dev` + `COPY`. No bundling: the image keeps real modules, so
a missing dependency fails loudly instead of being silently inlined away.
- **`docker:smoke` gates CI**: the built image is booted with a fixture
schema and probed over HTTP (`/health`, `/graphql?sdl`) in `nx affected`,
so both build-time and runtime-only failures (the
`ERR_MODULE_NOT_FOUND` class) fail the PR, not production.
- **Publishing is tag-triggered but registry-independent**: the release run’s
`@lde/search-api-server@<version>` tag triggers the Docker workflow, which
checks out that commit, rebuilds and smoke-tests the image, and pushes
`ghcr.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 is `import 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 `FROM` this 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:lockfile` repair step is a workaround for the Nx pruning
fallback; when Nx learns to prune this workspace’s lockfile it can be
dropped.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 16. Ship the search indexer as a config-driven image

Date: 2026-07-24

## Status

Accepted

Extends [ADR 15 (Ship the served search API as a Docker image built from the
workspace)](./0015-ship-the-served-search-api-as-a-docker-image-built-from-the-workspace.md).
Implements [#652](https://github.com/ldelements/lde/issues/652), the
write-path counterpart of #600/#646.

## Context

`searchIndexerPipeline()` (#647) composes the object-grain search indexer,
but still requires a Node program around it. The read path already ends in a
bootable image (ADR 15); the write path needs the same final layer, and the
symmetry is the point: mount the **same schema-declaration module** into both
images, point both at the same `TYPESENSE_*` coordinates, and the write and
read sides cannot disagree about the schema.

The one open design problem is the import path (`ImportResolver` +
`@lde/sparql-qlever`). Importing is not ‘have a QLever running’ but a
per-dataset control loop: download the dump, build the index offline,
(re)start the server on the new index. A statically-declared QLever service
(Docker Compose service, Kubernetes sidecar) therefore **cannot work**: it
only exposes a SPARQL port, and the pipeline has no control plane to rebuild
its index per dataset. Feeding a running server is not viable either – SPARQL
UPDATE’s update-triples overlay performs badly and deleted triples can
reappear after restart, and Graph Store HTTP `POST` is unusable for large
datasets (ad-freiburg/qlever#2014). A QLever the pipeline uses must be a
QLever the pipeline controls.

## Decision

- **A separate composition package, `@lde/search-indexer`**: environment
config, the shared schema-module loader, dataset selection against a
registry, and Typesense rebuild writers around `searchIndexerPipeline`.
Engine-bound to Typesense deliberately – that binding cannot live in
`@lde/search-pipeline` without breaking its engine-agnosticism. Published
to npm and shipped as `ghcr.io/ldelements/search-indexer`, reusing ADR 15’s
entire delivery pattern (workspace-built image, `docker:smoke` CI gate,
release-tag-triggered publish).
- **The schema-module loader moves to `@lde/search/module`** and both images
use it, implementing the shared-mount symmetry in code: one loader, one
validation, one error vocabulary. Consumer-specific optional exports
(`schemaOptions`, `engineOptions`) stay validated in the consumers.
- **Configuration is env-only and boot-or-fail**, every problem reported in
one error (ADR 15’s pattern): registry endpoint plus dataset IRIs or
criteria, `TYPESENSE_*`, rebuild mode, optional collection prefix, optional
file provenance + pipeline version. Two combinations are rejected at boot:
provenance with only one of its two halves, and provenance with blue-green
rebuilds – a skipped dataset would be missing from the fresh collection the
swap makes live.
- **v1 ships endpoint-only by default, plus the Docker-socket import path
behind a flag.** `QLEVER_IMAGE` turns on `ImportResolver` with
`DockerTaskRunner`: the indexer container spawns and fully controls sibling
QLever containers over the mounted socket. It is free (the runner exists
and talks to the socket via dockerode), Compose-friendly, and keeps QLever
out of the indexer’s cgroup. Its limits are documented, not worked around:
no socket, no import path – on containerd-based Kubernetes, run
endpoint-only.
- **Native mode in-image is the Kubernetes follow-up, not part of v1**:
shipping the QLever engine inside the indexer image (`NativeTaskRunner`)
works anywhere a container runs, but costs image size, engine-version
coupling and a shared cgroup (a QLever OOM kills the indexer). A
controllable QLever sidecar (own control agent and protocol) is only worth
designing if both other options prove unacceptable.

## Consequences

- Ops deploys configure the indexer (a cron job in Compose or Kubernetes)
instead of writing a Node program; deployments with bespoke root selectors
or per-stage tuning stay on the first-class library path
(`searchStages` + `searchIndexWriter` + `Pipeline`).
- One mounted module drives both images; schema drift between the write and
read side is no longer expressible in deployment config.
- The image runs batch-style: no ports, no `HEALTHCHECK`; overlapping runs
are serialized by the rebuild writers’ cross-pod lock.
- The import path requires Docker-socket access and an identical host path
for `DATA_DIR` in the indexer and its sibling QLever containers – both
documented in the package README, both moot in endpoint-only mode.
- When the Kubernetes need materializes, native mode lands as a second image
variant (or a base-image switch) without touching the configuration
surface: `QLEVER_IMAGE` is the only Docker-specific knob.
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default [
{
ignores: [
'**/dist',
'**/dist-docker',
'**/coverage',
'**/out-tsc',
'**/vite.config.*.timestamp*',
Expand Down
39 changes: 39 additions & 0 deletions nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,50 @@
"options": {
"testTargetName": "test"
}
},
{
"plugin": "@nx/docker",
"options": {
"buildTarget": "docker:build",
"runTarget": "docker:run"
}
}
],
"targetDefaults": {
"test": {
"dependsOn": ["^build"]
},
"docker:stage": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist-docker"]
},
"copy-workspace-modules": {
"executor": "@nx/js:copy-workspace-modules",
"dependsOn": ["docker:stage"],
"options": {
"outputPath": "{projectRoot}/dist-docker"
}
},
"prune-lockfile": {
"executor": "@nx/js:prune-lockfile",
"dependsOn": ["docker:stage"],
"options": {
"outputPath": "{projectRoot}/dist-docker"
}
},
"docker:lockfile": {
"executor": "nx:run-commands",
"dependsOn": ["copy-workspace-modules", "prune-lockfile"],
"options": {
"cwd": "{projectRoot}/dist-docker",
"command": "find workspace_modules -type d \\( -name src -o -name test \\) -prune -exec rm -rf {} + && find . -name '*.tsbuildinfo' -type f -delete && npm install --package-lock-only --ignore-scripts --legacy-peer-deps"
}
},
"docker:build": {
"dependsOn": ["docker:lockfile"]
}
},
"release": {
Expand Down
Loading