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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Each package uses conditional exports with a `development` condition for local d
- Test files use `.test.ts` suffix in `test/` directory
- Fixtures in `test/fixtures/`
- HTTP mocking with Nock
- Tests that start a local SPARQL endpoint (`@lde/local-sparql-endpoint`) must use unique ports across packages to avoid conflicts when Nx runs tests in parallel. Current port allocations: `dataset-registry-client` (3002), `pipeline` sparqlQuery (3001), `pipeline` executor (3003), `pipeline` provenance store (3004), `pipeline-void` namespace-normalization (3005–3006), `search-pipeline` extraction round-trip (3007), `search-pipeline` searchIndexerPipeline end-to-end (3008), `search-pipeline` registry-sourced extraction (3009), `search-pipeline` joins end-to-end (3010)
- Tests that start a local SPARQL endpoint (`@lde/local-sparql-endpoint`) must use unique ports across packages to avoid conflicts when Nx runs tests in parallel. Current port allocations: `dataset-registry-client` (3002), `pipeline` sparqlQuery (3001), `pipeline` executor (3003), `pipeline` provenance store (3004), `pipeline-void` namespace-normalization (3005–3006), `search-pipeline` extraction round-trip (3007), `search-pipeline` searchIndexerPipeline end-to-end (3008), `search-pipeline` registry-sourced extraction (3009), `search-pipeline` joins end-to-end (3010), `search-pipeline` keyed roots (3011)

### Key Dependencies

Expand Down
160 changes: 160 additions & 0 deletions docs/decisions/0022-key-a-root-type-on-a-declared-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# 22. Key a root type on a declared field

Date: 2026-08-21

## Status

Accepted

Extends [ADR 20](./0020-resolve-a-references-fields-from-the-targets-own-collection.md),
whose contract – a reference holds ids of documents in the target’s collection –
is what makes reference rewriting a consequence rather than a new rule. Relates
to [ADR 11](./0011-decouple-rdf-depth-from-the-api-surface.md) (inline
references, the other way a referent reaches a document) and
[ADR 12](./0012-bound-memory-by-the-unit-of-work-not-the-input.md).

## Context

A search document is keyed on the IRI of the node it was projected from. That is
the right default and wrong for a whole class of profiles: where a publisher
models an entity as its own node and states, in the graph, that the node _is_
some agreed term – SCHEMA-AP-NDE § 3.4 says exactly this for every
`DefinedTerm` – two publishers describing one place produce two documents, two
facet buckets and two entity pages, and neither can be reached from the other.

The fact that decides the key is in the graph, one hop from every node that
needs it, and already readable as an ordinary declared field. What was missing
was a way for the schema to say **which field holds the key**.

The alternative on the table was to move root selection into the deployment – a
`DISTINCT`/`BIND` selector minting roots that carry no triples, and a reader
transform told the batch’s bindings so it could mint content for them. That
works, but it puts a generic seam in `@lde/pipeline`, makes every keyed root an
empty CONSTRUCT, and writes the deployment’s rule twice: once as a SPARQL
`FILTER` in the selector and once in JavaScript in each transform that has to
rewrite a reference, with a standing obligation that the two never disagree.

## Decision

A Root Type is keyed on the node’s IRI unless it names a **`key` field** to read
the key from. A reference to such a type stores the target’s key.

```ts
key: {
field: '_sameAs',
pick: (candidates) => candidates.find(isGeoNames) ?? candidates.find(isCovered),
}
```

`key.field` names a declared field of the type: a `path`-bearing, `array`
reference field that is not `inline`. Naming a **declared field** rather than a
path or a pseudo-field is the whole point – the extraction branch already exists,
a reader transform that repairs reference values covers the candidates the day
the field is declared, and the field’s own `transform` is where IRI
normalisation lives, so two spellings of one IRI become one candidate before
anything chooses between them.

`key.pick` is the deployment’s choice among the candidates, defaulting to the
first. LDE never inspects an IRI’s shape – it asks.

### The guards

Candidates reach `pick` transformed, IRI-filtered, deduplicated and **sorted**,
so the default is deterministic whatever order the CONSTRUCT returned them in;
`pick` must return one of them or `undefined`, so a key is always either an IRI
the graph offered for that node or the node’s own, never one invented in
between. `pick` must be pure: the same function keys the document and every
reference to it, so an impure one could key those two differently and leave a
reference dangling.

`documentKeyOf` is that whole rule in one exported function, so a transform that
needs a node’s key before the projection runs reads the same answer the
projection will.

### The boundary, for keys and for joins alike

Only a reference that **names** its target – a `lookup`’s `target`, an
`idOnly`’s `labelSource` – is re-keyed. That is the same line a join draws, and
for the same reason: naming the target is what asserts that the field holds ids
of that collection’s documents. An `idOnly` reference with no label source, and
a `derive`d reference over a raw internal path, never claimed as much, so
nothing rewrites them.

## Consequences

- **Several nodes with one key are one document.** That is what a document key
means; the writer upserts by `id`. A deployment that wants the merged document
to carry particular content attaches a transform; one that does not gets
last-writer-wins, exactly as a shared entity across datasets behaves today.
The projection still emits one document per distinct root – folding them is
the writer’s upsert, not the projection’s.
- **Shared documents become the norm rather than the edge case**, which
multiplies the exposure of the single-valued provenance stamp: when one
contributor leaves the run while another that still references the entity is
skipped, the membership sweep can delete a document that is still referenced.
The design does not make this worse per document, but it makes it common.
- **The CONSTRUCT grows** one `OPTIONAL` hop per reference into a keyed type, and
the frame carries the referent’s key-field values. `OPTIONAL` rather than
conjoined, so an unaligned referent keeps its row – and its own IRI – instead
of dropping out of the extraction.
- **A transform that replaces a root’s quads must re-emit the key field.** The
existing rule – a field the document needs must be in the stream – applied to
one more field; a transform that only adds never meets it. Left as a
convention rather than a guarantee: the structural alternative (reading the key
off the reader’s raw output before transforms run) touches `@lde/pipeline`’s
runner, the one package this design otherwise leaves alone.
- **A transform that supplies key candidates must reach every referring type.**
A transform is attached to one type’s reader, and a reference’s key is read in
the _referring_ type’s extraction query – so candidates minted on the target
alone key the target’s own document while every reference to it still stores
the node IRI. Repairing candidates the graph already carries is unaffected
(a reader transform on the referring type covers its own hop); supplying them
is what has to reach both, or be supplied upstream.
- **The key is assigned before any `derive` runs**, so a derive sees the key and
never the node IRI. A deployment that wants the node IRI declares a plain
`idOnly` reference over the same path.
- **A cross-dataset node reference does not resolve.** A work in dataset A
pointing at a local node in dataset B gets no candidates – the hop runs against
A’s distribution – so it stores the node IRI and dangles against B’s keyed
document. Publishers reference other publishers through `sameAs` rather than
directly, and such a reference is already unresolvable today for every purpose
but labels.
- `@lde/pipeline`, `@lde/search-indexer` and the API packages are untouched, and
`@lde/search-typesense` only adopts the shared `rootTypeNamed` in place of a
by-name map of its own: the change is a schema member, the projection, and one
hop in the extraction generator. A schema declaring no `key` extracts,
projects, indexes and queries exactly as before.

## Rejected

**A deployment-supplied selector plus `bindings` on reader transforms.** Correct,
but it solves _ids come from the selector_ by moving the selector to the
deployment rather than by letting the schema say what a key is. It reaches the
goal at the cost of a generic seam in `@lde/pipeline`, an empty CONSTRUCT per
keyed root, and a rule every consumer must restate in two languages.

**Framing the member in Linked Data sameness vocabulary** (`identity: {
alignment, canonical }`). Same mechanics, but it made LDE state rules – “several
nodes merge”, “references are rewritten” – that in search-document terms are
just what keys already do. _Identity_, _alignment_, _canonical_ and _authority_
are the deployment’s words, and stay in the deployment’s schema.

**`key: { path }` with a pseudo-field.** A pseudo-field is invisible to
everything that works on declared fields: a reader transform that enumerates
reference-field aliases would not see it, so a publisher writing the alignment as
a typed literal would silently yield no candidates; IRI normalisation would have
nowhere to live, so two spellings of one IRI would silently fail to merge; the
extraction would need an extra root branch; and the alias would become exported
surface. Naming a declared field removes all of it.

**A derivable `id`.** A `derive` over the document key re-keys the document but
cannot reach a reference on another type: the hop to the referent’s key field has
to be _extracted_, and only a declaration the extraction generator can see makes
that happen.

**Putting `key` in the indexer’s extensions** rather than in the schema. It keeps
the predicate in app code next to the deployment’s other logic, but the
extraction generator and the projection both need it, so it would have to be
threaded through the stage factory and the pipeline – the ripple the schema
placement avoids. A transform reads the declaration off the loaded schema, so
nothing is lost.
19 changes: 19 additions & 0 deletions docs/reference/search-indexer.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,25 @@ declare a `path`.** Projection skips a field with neither a `path` nor a
`derive`, so a transform-minted IR Alias is otherwise never read and the field
ships empty.

The same rule bites once more where a root type declares a
[document key](./search#document-key): **a transform that replaces a root’s
quads must re-emit the key field.** The key is read off the projected frame like
any other field, so a replaced root that drops it is keyed on its node IRI
instead – and every reference to it, which is keyed independently, then points
at a document that was never written. A transform that only adds quads never
meets _this_ rule.

An adding transform has its own version of the same trap, because **a transform
is attached to one type’s reader**, and a reference’s key is read in the
_referring_ type’s extraction query. Mint key candidates on `Place` – a
reconciliation step adding `schema:sameAs` – and `Place` documents are keyed on
them, while the `CreativeWork` stage’s hop still runs against the untransformed
endpoint, finds nothing, and stores the publisher’s node IRI. Every reference
then points at a document that was never written. Where a transform supplies key
candidates rather than repairing them, attach it to **every type that references
the keyed one** as well, or supply them upstream (in the import, or in a reader
of your own) so both queries see them.

## Compose it yourself

Reach for this only when the deployment needs something `createSearchIndexer`
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/search-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,16 @@ guarantees one output triple per genuine value:
fields are UNION’d off the referent variable, so even a multi-hop nested
template never conjoins independent multi-valued fields – only the intermediate
link triple repeats, and only linearly.
- **A key hop stays inside its branch.** A reference naming a target that
declares a [document key](./search#document-key) gets its branch extended with
an `OPTIONAL` hop reading the referent’s key field, emitted under the target’s
own alias – so the projection can store the referent’s key rather than its node
IRI. It sits inside that reference’s UNION branch, so it never cross-multiplies
against another field; like an inline reference’s link triple, the reference’s
own template triple then repeats once per key candidate – linearly, and only
for a referent that carries several. `OPTIONAL`, so a referent with no key
candidate keeps its row. The root side is unchanged: a key field is a declared field, so its own
branch and template triple are already there.

Because the queries are duplicate-free by construction, correctness and bounded
output volume do **not** depend on a client-side **post-processing deduplication
Expand Down
105 changes: 104 additions & 1 deletion docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,108 @@ resolves labels from; without it, everything keeps serving `label`. A facet
bucket’s `label` is unaffected: it is per-facet-field, and a per-type name would
make the bucket shape non-uniform.

### Document key

A Root Type is keyed on the node’s IRI unless it names a **`key` field** to read
the key from. `key` has the shape of `labelField` – _which field is the label_ –
and says _which field holds the key_:

```ts
const place = defineSearchType({
name: 'Place',
class: `${SCHEMA}Place`,
labelField: 'name',
key: {
field: '_sameAs',
// A preference order, not a filter: GeoNames first, then any other source
// an authority can resolve; nothing matched keeps the publisher’s node.
pick: (candidates) =>
candidates.find(isGeoNames) ?? candidates.find(isCovered),
},
fields: [
{
name: 'name',
kind: 'text',
locales: ['nl', 'und'],
path: `<${SCHEMA}name>`,
output: true,
searchable: { weight: 3 },
},
{
// Internal (no role): read for the key, pruned before the writer.
name: '_sameAs',
kind: 'reference',
array: true,
path: `<${SCHEMA}sameAs>`,
transform: normaliseIri,
},
],
});
```

- **`key.field`** names a declared field of the type: a `path`-bearing, `array`
reference field that is not `inline`. Its values are the key candidates.
Because it is an ordinary field, everything that already applies to fields
applies to the candidates – it is extracted like any field, a reader transform
that repairs reference values covers it, and the field’s own `transform` is
where IRI normalisation lives, so two spellings of one IRI become one
candidate before anything chooses between them.
- **`key.pick`** chooses among the candidates: `(candidates) => key | undefined`,
where `undefined` keeps the node’s own IRI. It defaults to the first candidate,
and is not consulted for a node whose key field is empty.
- **The guards.** Candidates reach `pick` transformed, IRI-filtered, deduplicated
and **sorted**, so the default is deterministic whatever order the CONSTRUCT
returned them in. `pick` must return one of them or `undefined` – anything else
throws, naming the node and its candidates – so a key is always an IRI the
graph offered for that node. And `pick` must be **pure**: the same function
keys the document and every reference to it.

`documentKeyOf` (from `@lde/search/adapter`) is that whole rule in one function,
for a transform that needs to know a node’s key before the projection runs.

Two consequences are not new rules, only what a document key already means:

- **Several nodes with one key are one document.** The writer upserts by `id`. A
deployment that wants the merged document to carry particular content attaches
a transform; one that does not gets last-writer-wins, exactly as a shared
entity across datasets behaves today.
- **A reference stores the target’s key.** A `lookup`’s `target` and an
`idOnly`’s `labelSource` already mean _this field holds ids of documents in
that collection_ – the contract a label lookup and a join rely on – so a
reference that names a keyed target stores the referent’s key rather than its
node IRI. A reference that names no target is never rewritten: it claimed
nothing about a collection. The extraction adds one `OPTIONAL` hop per such
reference to read the referent’s key field, so an unaligned referent keeps its
row and its own IRI.

What LDE deliberately does not know is _why_ one candidate is preferred over
another, and what a merged document should say. **LDE decides the key; the
deployment decides the content.**

Things to keep in mind when declaring one:

- A transform that **replaces** a root’s quads must re-emit the key field – the
existing rule that a field the document needs must be in the stream, applied to
one more field. A transform that only adds never meets that rule, but a
transform that **supplies** key candidates has a mirror of it: a transform is
attached to one type’s reader, and a reference’s key is read in the referring
type’s query, so candidates minted on the target alone leave every reference
keyed on the node IRI. See
[Add a transform](./search-indexer#add-a-transform).
- The key is assigned **before any `derive` runs**, so a derive sees the key and
never the node IRI. A deployment that wants the node IRI declares a plain
`idOnly` reference over the same path.
- The **referring** field’s own `transform` runs on what it stores, which for a
keyed target is the key rather than the referent’s node IRI. Declare the two
together only deliberately.
- A work in dataset A referencing a node **in dataset B** gets no candidates (the
hop runs against A’s distribution), so it stores the node IRI and does not
resolve against B’s keyed document. Publishers reference each other through
`sameAs` rather than directly, and such a reference is already unresolvable
today for every purpose but labels.

See [ADR 22](../decisions/0022-key-a-root-type-on-a-declared-field).

### Projecting what a lookup carries

A `lookup` declares no field list. What it _fetches_ is named per query, by a
Expand Down Expand Up @@ -728,7 +830,8 @@ reported to `onIgnoredFilter`, since neither compiled as written.

### Lookup by IRI

Every type is filterable on **`id`** – the document’s IRI – without declaring
Every type is filterable on **`id`** – the document’s key, which is the node’s
IRI unless the type declares a [`key` field](#document-key) – without declaring
it, and every surface returns it. It is the one field no `SearchType` declares,
because every indexed thing already carries it: it is the hit’s identity
(`SearchHit.id`), not a value in its `ResultDocument`. `searchSchema()` rejects
Expand Down
Loading