Explanation: Architecture
Start
OverviewConceptsGuides
Run and configureREST API walkthroughCommand lineQuery groupsNotebooksRules and SRLPrefixesTestingMCP clientsPublish as toolsQuery bench in chatStatic exportETLDeployingReference
ConfigurationFeature flagsEntity modelSRL languageScriptsExplanation
ArchitectureVersioningEnvironmentsStorage and cachingSecurity modelRDF PatchInline inputsExamples
Sudoku solverArchitecture
This page explains how sqlib is put together: what the packages are, what runs in which process, how a request to execute a query travels, and why a few of the load-bearing decisions were made the way they were. It assumes the vocabulary in concepts.md.
The shape of the system
sqlib is a pnpm monorepo. One package is a Fastify server that owns all behaviour; the rest are either things the server depends on, things that wrap it, or things that run without it.
| Package | What it is | Depends on |
|---|---|---|
api |
The Fastify server: routes, persistence, execution, auth, cache | contracts, types, tools,
srl, rdf-delta, runtime,
compiler, runtime-duckdb,
runtime-etl, runtime-srl |
web |
The Nuxt 4 single-page application | types, runtime (and contracts
by path alias) |
mcp-server |
An MCP server that mounts the Fastify app in process | api, contracts, tools,
types |
contracts |
Generated JSON Schema and zod schemas for every entity and route | — |
tools |
The MCP tool catalogue: 89 tool definitions and a registry | contracts |
types |
Shared constants and types, including the feature-flag table | — |
srl |
The SPARQL RL parser, compiler and stratifier | — |
rdf-delta |
Derives what a SPARQL update would add and remove, without running it | — |
compiler |
The SPARQL parser: parse, detect parameters, apply arguments, compile verified templates | runtime |
runtime |
The parser-free runtime a static export bundle runs on | — |
runtime-oxigraph |
An Oxigraph-backed executor for that runtime | — |
runtime-duckdb |
The sandboxed DuckDB that reads ETL sources, under a profile the host supplies | — |
runtime-etl |
The ETL loop: rows in chunks, mapped to bindings, spliced into a CONSTRUCT, RDF to a sink | runtime |
runtime-srl |
The rule set runner: DATA blocks, then each stratum to its fixpoint, on an Oxigraph store | srl |
cli |
The sqlib command line: queries, ETL jobs and rule sets
in one process, no server |
compiler, runtime,
runtime-oxigraph, runtime-duckdb,
runtime-etl, runtime-srl,
srl |
Eight packages are publishable, that is, not
private: true: runtime and
runtime-oxigraph, which a static export loads in a browser
or in Node, and compiler, srl,
runtime-duckdb, runtime-etl,
runtime-srl and cli, which the
sqlib command line is built from. The first two ship
CommonJS and ESM; the other six run in Node only and ship ESM.
Everything else is internal to the repository.
Why the split is where it is
The split follows three lines, and each one is a dependency direction somebody needed rather than a tidiness exercise.
A package exists where a second host needs the same
code. runtime holds the argument-application code
— serializeTerm, alignArgumentSets,
substituteLimitOffset — and compiler, which
holds the parser, imports it rather than reimplementing it. The comment
at that import states the reason: importing rather than copying is what
makes "the client substitutes exactly as the server does" true by
construction. The same argument produced rdf-delta as a
package with no Fastify, no fs and no network dependency:
server-side apply, browser-side apply and worker-side apply are then the
same code, and where the patch log eventually lives becomes a choice of
sink rather than a rewrite. compiler follows the same line:
the parser, input detection and template compilation run in the API and
in the command-line tool, so neither carries a copy. The API wraps it
only to send its diagnostics to OpenTelemetry.
runtime-duckdb follows the same line: the API and the
command-line tool read ETL sources through one sandbox. The API supplies
a profile read from the environment; the tool supplies one from its
flags. runtime-etl holds the loop those rows go through, so
a job gives the same RDF in either host;
packages/api/test/lib/etlParity.test.ts holds the two to
that. runtime-srl does the same for rule sets: the API's
RuleSetExecutor is now the host that loads, validates and
stratifies stored versions, and the runner evaluates them.
A package exists where two consumers must agree about a
document. contracts holds the generated schemas,
and tools holds the MCP catalogue that imports them. See Generated
schemas below.
A package exists where the thing has its own
grammar. srl is a parser, compiler and stratifier
for a language, with no knowledge of libraries, HTTP or storage.
The split that is deliberately not made is api
itself. Persistence, execution, orchestration, auth and the cache all
live in one package, because they are mutually recursive: the entity
store executes SPARQL through the same executor factory that runs a
user's query, and the executor factory reads backend entities out of the
cache the entity store populates. Splitting them would need an interface
at every point in that cycle for no change in behaviour.
The two deployables
A container image, built from the repository-root
Dockerfile. One image, three modes, selected by
APP_MODE:
api— the Fastify server alone.mcp-http— the MCP server over streamable HTTP, which boots the Fastify app inside itself and registers/mcpon it.dual-http(the image default) — the same Fastify instance serving both the REST API and/mcpon one port.
A static site, packages/web, built by
Nuxt with ssr: false. It is a browser application that
talks to the API over HTTP; there is no Nuxt server in a deployment. It
reads /health at boot to discover the API's auth mode and
skips its login flow when auth is disabled, so the same bundle works on
both sides of that setting.
A third artifact is not a deployable in the same sense:
GET /libraries/:id/export-bundle compiles a library into a
self-contained bundle that packages/runtime executes in a
browser with no sqlib server present. See guides/static-export.md.
The request path for executing a query
POST /execute with a target IRI is the path most of the
system participates in. In order:
- The auth plugin's
onRequesthook builds anAuthContextfor the request. Indisabledmode — the default — it is a synthetic full-access context, so every later enforcement point exists and passes. Indry-runandrequiredmodes it verifies the bearer token against a configured OIDC issuer and resolves the caller's grants. - Route validation against the generated JSON Schema
for
/execute. - Target resolution from the cache.
cacheCoordinator.get(targetId)returns theQuery,QueryVersion,QueryGrouporQueryGroupVersionsynchronously from memory. A stable pointer is resolved to itscurrentVersion; a version IRI is used as given. - Authorization on the owning library.
requireLibraryMode(request, targetLibrary, 'execute'). Execute is a mode of its own, independent of read. - An
ExecutorFactoryscoped to this request. Every executor used for the request — including each leg of a query group, which acquires its own through the execution engine — comes from this one factory, which callsassertBackendAccessbefore handing one out. The comment on its constructor states the reason: a query group leg cannot run against a backend the caller may not reach, and the property holds by construction rather than by remembering to check in each orchestrator. - Argument application.
applyExecutionArgumentssubstitutes namedLIMIT/OFFSETparameters first, then binds theVALUESarguments.VALUESbinding operates on the parsed query. Page parameters are found and substituted on the query's tokens — comments, string literals and IRIs are skipped, so text that only reads likeLIMIT 0001is never a parameter — because the parser does not keep the000lexeme that names one. The same function servesPOST /sparql, which is what makes an unsaved query in the editor run the way a saved one does rather than approximately so. - Execution through an
ISparqlExecutor— HTTP with credentials resolved from the environment, or an in-process Oxigraph store. - Response, with a
Server-Timingheader splitting database time from application time, and per-node timings for a query group.
A query group replaces steps 6–8 with the orchestration engine, which resolves the node graph, executes nodes in dependency order and carries each node's output along its outgoing edges as the next node's argument.
Generated schemas: one source, three consumers
The entity schemas under
packages/api/src/persistence/schemas/ are the intermediate
representation for the whole data model.
packages/api/scripts/generate-schemas.ts reads them and
writes:
packages/contracts/src/schema/entities.generated.ts— a JSON Schema per entity type.packages/contracts/src/schema/routes.generated.ts— a JSON Schema per route, built from those entity schemas.packages/contracts/src/generated/*.ts— zod schemas and TypeScript types per entity.
Three consumers read the result, and none of them regenerates it:
- The API.
packages/api/src/index.tsimports* as schemas from '@sparql-query-lib/contracts/schema'and registers every document that carries an$idwith Fastify. Those same documents are what@fastify/swaggerrenders at/docs, so the OpenAPI description is the validation, not a description of it. - The MCP tool catalogue.
packages/tools/src/tool-schemas.tsimports route schemas from@sparql-query-lib/contracts/schema/routesand uses them directly as toolinputSchemas. Its own comment states the consequence: MCP and HTTP cannot disagree about what a valid payload is, because there is only one document. The tools that take path or query arguments rather than a body —id,version,accept— have nothing in contracts to correspond to, so eleven argument shapes are written out once in that file and shared across the catalogue. - The web application. It imports zod schemas and
types from
@sparql-query-lib/contractsthrough a Nuxt path alias that resolves to the package source, sopnpm devdoes not depend on the package having been built.
There is a fourth reader of one narrow part: the MCP server compiles
its tool validators with createValidatorAjv, re-exported
from the API's own entry point. The comment there gives the reason:
coerceTypes and useDefaults change what a
handler receives, so an MCP server building its own ajv would be a
second validator configuration free to drift from the server's.
The pipeline is run by
pnpm --filter @sparql-query-lib/api generate-schemas, and
the API's build script runs it before compiling. Editing a
generated file by hand is undone by the next build; the entity schema is
what changes.
Why the MCP server mounts the Fastify app in process
packages/mcp-server does not speak HTTP to the API. It
calls configureApp to build a Fastify instance and then
dispatches every tool call through app.inject:
const response = await app.inject(withAuth as InjectOptions);Three consequences follow, and each is relied on somewhere.
One implementation of every operation. A tool is a
request the API already serves. Nothing in packages/tools
reimplements a route's behaviour; it builds a request and reads a
response. The catalogue's schemas are the routes' schemas, so a payload
accepted over HTTP is accepted over MCP.
Every hook runs. The change-feed hook
(registerChangeFeedHook) is a single onSend
hook on mutating responses. Because an MCP write arrives through
app.inject, it passes that hook exactly as the web
application's own HTTP write does, and no MCP-specific code has to
remember to announce a change. The same holds for the auth plugin's
onRequest hook and for the no-store hook that
keeps browsers from serving a saved-over entity from their own
cache.
The caller's token rides along.
injectCaller merges the inbound Authorization
header into the injected request, so a tool runs under the caller's
grants rather than an ambient service identity. Its comment states the
position plainly: the MCP server enforces nothing — it is a client.
The cost is that the change feed is a module-level emitter and
therefore reaches subscribers in the same process only. Under
dual-http the API and /mcp are one Fastify
instance, which is what the feed needs. Under the stdio
transport the MCP server boots its own API instance in its own process,
so a write there is not heard by a browser subscribed to a different
process. This is recorded in changeEvents.ts rather than
worked around.
Persistence and the entity cache
sqlib stores its own entities as RDF, in a SPARQL store, through its own query execution path.
The layer was originally built on an RDF mapping library, since removed. What replaced it is:
packages/api/src/persistence/EntityStore.ts— the engine. It is keyed on the schema rather than on an entity-type name, generates the SELECT, INSERT, DELETE and UPDATE for that schema, and executes them through the executor resolved forLIBRARY_STORAGE_BACKEND_ID. That indirection is what makes the RDF storage modes work without this file knowing which is configured. In the defaultsqlitemode each function hands over topackages/api/src/persistence/sqliteEntityStore.ts, which returns the rows the SELECT would.packages/api/src/persistence/SelfHostedAdapter.ts— the type-keyed facade the cache talks to. It maps an entity type to its schema throughSCHEMA_BY_TYPEand hands off toEntityStore.packages/api/src/lib/EntityRegistry.ts—LENS_BY_TYPE, the per-entity repositories (52 entity types), andTTL_MS, the per-type cache lifetime.
The name LENS survives in the registry; the
implementation behind it does not. The entity interfaces are named
Persisted* (PersistedQuery,
PersistedBackend, ...). Any older material describing
library lenses, a persistPath RocksDB store, or seven route
modules is describing a system that no longer exists.
Reads are served synchronously from an in-memory cache; writes go to
the store first and then update the cache. The details — why versions
never expire, what CACHE_PRELOAD and
CACHE_WRITE_THROUGH do, and what the four
INTERNAL_BACKEND_TYPE choices cost — are in storage-and-caching.md.
The API surface
packages/api/src/routes/ holds twenty-five files:
twenty-three route plugins, route-helpers.ts (shared
handler scaffolding and the one error answer) and
versionedEntity.ts, the router that produces the ten CRUD
routes every versioned entity shares — list, create, get, update,
delete, and the same five under /:id/versions. Queries,
query groups, rules, rule sets, data blocks, data graphs, tuple sets and
tests register through it; their modules hold only what is theirs
(schemas, how a version is written and shown, entity-specific routes).
The rules it applies — status codes, If-Match, pinned deletes — are in
versioning-and-immutability.md.
Grouped by what they are for:
- Containers and classification.
libraries,tags. - Callables and their versions.
queries,query-groups,rules,rule-sets,data-blocks. - Inputs.
argument-sets,argument-set-schemas,tuple-sets,data-graphs. - Running things.
execute(saved callables),sparql(a proxy for arbitrary query text),playground(unsaved rules and ETL, executed against ephemeral entities),detection(parse a query for its parameters and outputs, validate, format). - Judging things.
tests,benchmarks. - Backends and writes to them.
backends,patches. - Operations.
auth(identity introspection and grant administration),events(the change feed as Server-Sent Events),etl-jobs,assistant.
Outside the route modules, src/server/ holds the startup
pieces index.ts composes: bootstrap.ts (crash
handlers, .env, OpenTelemetry — imported first),
swagger.ts, health.ts,
metrics.ts, errorHandler.ts,
seed.ts and registerRoutes.ts, which registers
/ (a redirect to the docs UI), /health,
/metrics and every route plugin.
Registration is gated by feature flags. queries off
means /queries, /execute, /sparql
and /detect-* are never registered at all, not that they
answer 403; each playground route has its own flag. Four route groups
are deliberately not gated, and registerRoutes.ts
records why in each case: tags, tuple sets and data graphs because they
are inputs to more than one section and gating them would leave entities
referencing versions nothing could resolve; patches because a patch is a
property of a backend rather than of a library section. The flag table
itself is in reference/feature-flags.md.
Related
- versioning-and-immutability.md — why an entity splits into a stable pointer and frozen versions.
- storage-and-caching.md — where the library lives and what the cache guarantees.
- security-model.md — what is authenticated and what is not.
- rdf-patch.md — how
rdf-deltaderives an update's effect before applying it. - guides/deploying.md — running the image and the web app.
- reference/configuration.md — every environment variable.