You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Enrichment that draws on data outside the graph – an HTTP service, a store, a file –
already has a supported place to run. A QuadTransform attached to a reader sees the
extraction CONSTRUCT’s output for a batch of roots, can tell an absent value from a
present one, and may mint IR Aliases through the exported irAlias(); the prohibition in CONTEXT.md is on hand-written alias strings that can drift, not on the function. #675
settled that, and closed for it. Contributing before projection is an advantage rather
than a workaround: the enriched value is shaped by the schema like any other, instead of
the enricher re-implementing physicalFields() and its per-locale display, search and
sort variants.
What has no home is everything around the fetch. Every deployment that writes such a
transform re-solves the same five problems:
A durable place for a resolved fact, keyed on something stable, that survives an
index rebuild. The indexer reads publisher RDF live per dataset (or via an ephemeral
per-dataset QLever), ADR 16
rules out an LDE-owned store, and the search index is rebuilt from its sources – so a
fact fetched from outside has nowhere to live between runs.
The caller selects what needs resolving and applies what comes back. The resolver knows
only references, keys and facts – no item generic, no select, no apply, and therefore
nothing about quads, documents, stages or pipelines. That matters concretely: inside a QuadTransform, “applying” means minting quads, which no apply: (item, facts) => item
can express.
a per-run volume cap, so a cold or purged store warms over successive runs
backoff and retry
per-key failure isolation, surfaced as Result rather than a throw
failure reporting through an injected callback
No dependency on @lde/pipeline. Testable without constructing a pipeline and usable
by anything doing bounded outbound work. The two things that pull toward coupling are both
injectable: reporting is a callback rather than a ProgressReporter import, and the batch
memory bound is satisfied by the caller passing one batch at a time.
Because it is standalone, a deployment calls it from inside its own QuadTransform.
No pipeline adapter, no second extension point, no ADR amendment – the transform is the
seam, and this is a library the transform calls.
One constraint on #688 that follows: the concurrency map must land in a leaf package,
not in @lde/pipeline. Promoting it into the pipeline would give the resolver a pipeline
dependency through the back door and lose the property above.
Naming
Transforms rewrite what the graph said; resolvers fetch what it only pointed at.
That rule distinguishes this from ADR 2’s quad transforms without needing to read an
ADR, and it is why this is not called “enrichment” – which says only that something
got better, not where the data came from.
User Stories
As a Deployment, I want to fill fields from an external service without re-solving
storage, bounding, retry and isolation each time.
As a Deployment, I want resolved facts to survive an index rebuild, so that a
rebuild does not re-fetch every external reference.
As a Deployment, I want a failing resolution to degrade one reference rather than
abort the run, and to be told which degraded and why.
As a Deployment, I want outbound work bounded globally and per host, so that one
slow host cannot starve the run and no host is hammered.
As a Deployment, I want a cold store to warm over several runs rather than in one
burst against someone else’s infrastructure.
As a maintainer, I want the resolver testable without a pipeline, so its edge cases
are covered by unit tests rather than integration runs.
Open questions
Invalidation. The store is the only place the fact lives, so a wrong fact is
durable. Neither known consumer has a validator to revalidate against – the Network of
Terms is GraphQL, and IIIF info.json responses are inconsistently conditional – so v1
should expose purge-by-key plus a TTL and let definitions layer policy. Modelling ETag/Last-Modified revalidation, and multiple key spaces for facts of differing
volatility, should wait for a consumer that actually has validators.
Store backend.FileProvenanceStore (Ship a file-backed ProvenanceStore in @lde/pipeline #634) is the precedent for the seam, but not
for the default: it reads the whole file into memory on every access and is
single-writer, which suits one record per dataset and not one per external identifier
accumulating across every dataset and run. Node 24 ships node:sqlite, so a SQLite
backend costs no dependency and gives keyed reads, a TTL query and atomic writes.
Proposed: interface as the deliverable, SQLite as the default, in-memory for tests.
First consumer
netwerk-digitaal-erfgoed/stack#1 – @ndes/iiif-images, resolving IIIF image
dimensions and sizes ladders for the search index. It exercises every part: one
manifest fetch per record, info.json per image service, per-host bounds against
publisher infrastructure, facts that must outlive rebuilds, and records that must
still index when a fetch fails.
Second consumer
https://codeberg.org/limburg/lol/issues/110 – Linked Open Limburg, resolving GeoNames
coordinates for places through the Network of Terms. It pulls the design in usefully
different directions from the first:
One host, batched.lookup(uris: […]) takes a list, so a batch of roots is a single
request and the stage’s maxConcurrency already bounds what is in flight. Per-host
limiting buys it little – and cannot see the host that matters, since the Network of
Terms fans out to NDE’s GeoNames store behind the API. What protects that store is the volume cap, not per-host concurrency.
No validators. GraphQL, so nothing to revalidate against, and a place’s coordinates
do not drift. Purge-by-key plus a TTL is sufficient – evidence for deferring validator
support rather than designing it now.
A register-wide store. The key is the canonical term IRI, so entries accumulate
across every dataset and every run; shared places are the point, and a newly selected
dataset referencing a place already resolved must cost no outbound call. This is the
consumer that makes a whole-file-in-memory default the wrong one.
Between them the two consumers cover many hosts vs one, per-record vs per-term, validators
vs none, and dataset-scoped vs register-wide storage – which is a better basis for the
interface than either alone.
Problem Statement
Enrichment that draws on data outside the graph – an HTTP service, a store, a file –
already has a supported place to run. A
QuadTransformattached to a reader sees theextraction CONSTRUCT’s output for a batch of roots, can tell an absent value from a
present one, and may mint IR Aliases through the exported
irAlias(); the prohibition inCONTEXT.mdis on hand-written alias strings that can drift, not on the function. #675settled that, and closed for it. Contributing before projection is an advantage rather
than a workaround: the enriched value is shaped by the schema like any other, instead of
the enricher re-implementing
physicalFields()and its per-locale display, search andsort variants.
What has no home is everything around the fetch. Every deployment that writes such a
transform re-solves the same five problems:
index rebuild. The indexer reads publisher RDF live per dataset (or via an ephemeral
per-dataset QLever),
ADR 16
rules out an LDE-owned store, and the search index is rebuilt from its sources – so a
fact fetched from outside has nowhere to live between runs.
still private to
distribution-probe(Share the host-limited concurrent map #688).aborting a run, and the run is told which degraded and why.
service its whole backlog at once.
That is complexity pushed up into every consumer. #675 closed for the right reason – a
slot absorbs nothing. A library does.
Solution
A deep module –
@lde/resolver– with a narrow interface:The caller selects what needs resolving and applies what comes back. The resolver knows
only references, keys and facts – no item generic, no
select, noapply, and thereforenothing about quads, documents, stages or pipelines. That matters concretely: inside a
QuadTransform, “applying” means minting quads, which noapply: (item, facts) => itemcan express.
Inside, and never surfaced unless overridden:
key, behind an interface (Move provenance RDF/vocabulary to the consumer; keep ProcessingRecord + ProvenanceStore as the TS seam #473 keepsProvenanceStoreasa TS seam with the vocabulary moved to the consumer – same shape here)
distribution-probe(Share the host-limited concurrent map #688). Worth noting that libraries often get this wrong and cannot be relied on: the
IIIF consumer’s client throttles on a single global counter borrowed from an unrelated
config key (ImageServiceLoader: verificationsRequired doubles as the fetch concurrency cap IIIF-Commons/iiif-helpers#39), so the resolver has to own bounding rather
than delegate it.
Resultrather than a throwNo dependency on
@lde/pipeline. Testable without constructing a pipeline and usableby anything doing bounded outbound work. The two things that pull toward coupling are both
injectable: reporting is a callback rather than a
ProgressReporterimport, and the batchmemory bound is satisfied by the caller passing one batch at a time.
Because it is standalone, a deployment calls it from inside its own
QuadTransform.No pipeline adapter, no second extension point, no ADR amendment – the transform is the
seam, and this is a library the transform calls.
One constraint on #688 that follows: the concurrency map must land in a leaf package,
not in
@lde/pipeline. Promoting it into the pipeline would give the resolver a pipelinedependency through the back door and lose the property above.
Naming
Transforms rewrite what the graph said; resolvers fetch what it only pointed at.
That rule distinguishes this from ADR 2’s quad transforms without needing to read an
ADR, and it is why this is not called “enrichment” – which says only that something
got better, not where the data came from.
User Stories
storage, bounding, retry and isolation each time.
rebuild does not re-fetch every external reference.
abort the run, and to be told which degraded and why.
slow host cannot starve the run and no host is hammered.
burst against someone else’s infrastructure.
are covered by unit tests rather than integration runs.
Open questions
durable. Neither known consumer has a validator to revalidate against – the Network of
Terms is GraphQL, and IIIF
info.jsonresponses are inconsistently conditional – so v1should expose purge-by-key plus a TTL and let definitions layer policy. Modelling
ETag/Last-Modifiedrevalidation, and multiple key spaces for facts of differingvolatility, should wait for a consumer that actually has validators.
FileProvenanceStore(Ship a file-backed ProvenanceStore in @lde/pipeline #634) is the precedent for the seam, but notfor the default: it reads the whole file into memory on every access and is
single-writer, which suits one record per dataset and not one per external identifier
accumulating across every dataset and run. Node 24 ships
node:sqlite, so a SQLitebackend costs no dependency and gives keyed reads, a TTL query and atomic writes.
Proposed: interface as the deliverable, SQLite as the default, in-memory for tests.
First consumer
netwerk-digitaal-erfgoed/stack#1 –
@ndes/iiif-images, resolving IIIF imagedimensions and
sizesladders for the search index. It exercises every part: onemanifest fetch per record,
info.jsonper image service, per-host bounds againstpublisher infrastructure, facts that must outlive rebuilds, and records that must
still index when a fetch fails.
Second consumer
https://codeberg.org/limburg/lol/issues/110 – Linked Open Limburg, resolving GeoNames
coordinates for places through the Network of Terms. It pulls the design in usefully
different directions from the first:
lookup(uris: […])takes a list, so a batch of roots is a singlerequest and the stage’s
maxConcurrencyalready bounds what is in flight. Per-hostlimiting buys it little – and cannot see the host that matters, since the Network of
Terms fans out to NDE’s GeoNames store behind the API. What protects that store is the
volume cap, not per-host concurrency.
do not drift. Purge-by-key plus a TTL is sufficient – evidence for deferring validator
support rather than designing it now.
across every dataset and every run; shared places are the point, and a newly selected
dataset referencing a place already resolved must cost no outbound call. This is the
consumer that makes a whole-file-in-memory default the wrong one.
Between them the two consumers cover many hosts vs one, per-record vs per-term, validators
vs none, and dataset-scoped vs register-wide storage – which is a better basis for the
interface than either alone.