Skip to content

feat(search)!: filter across collections through declared joins - #719

Open
ddeboer wants to merge 1 commit into
mainfrom
feat/search-joins
Open

feat(search)!: filter across collections through declared joins#719
ddeboer wants to merge 1 commit into
mainfrom
feat/search-joins

Conversation

@ddeboer

@ddeboer ddeboer commented Aug 10, 2026

Copy link
Copy Markdown
Member

Gives the query model cross-collection filtering by declaring joins on the edges
the schema already describes, so “every object published by institution X”
becomes one query instead of two round trips.

creativeWorks(where: {
  dataset: { where: { publisher: { where: { id: { in: [$institution] } } } } }
})

compiles to filter_by: $datasets($publishers(id:=X)) – one engine round-trip,
with a correct total, ranking and facet counts.

Fix #712. Decisions and their reasoning: ADR 19.

What changed

@lde/searchjoinable: true on a reference field, valid only
alongside labelSource (which already asserts its values are ids in that
type’s collection). joinGraph(schema) holds everything that follows: the
components a rebuild is scoped by, and what a query path resolves to. Built
eagerly by searchSchema, so the schema-wide rules – one joinable reference per
target, no cycles – fail at startup. A criterion gains an on path, capped at
three hops in the IR so a later REST surface inherits the cap; where stays the
flat conjunction of disjunctions ADR 18 made it.

@lde/search-typesense – a joinable reference is emitted as a reference
field targeting .id, with async_reference: true and cascade_delete: false
(all three forced, reasons in the ADR). An on path compiles to nested
$collection(…) clauses, the leaf compiled against the target type’s
declaration. InPlaceRebuild fails loudly when an existing collection lacks a
declared reference, naming the drop-and-rebuild – it would otherwise index and
commit happily and then 400 on every join.

@lde/search-pipeline – the join component is the unit of rebuild: runs
open referenced-first (a collection cannot reference one that does not exist
yet) and commit per component, referrers-first (a blue/green commit drops the
collection it supersedes). A type with no joinable edge is a singleton
component, so a schema without joins behaves exactly as before.

@lde/search-api-graphql – a joinable reference takes
‹Target›ReferenceFilter @oneOf { in, where }, one per target and shared by
every field pointing at it; a non-joinable one keeps StringFilter, so the
capability difference is visible in the schema rather than a runtime error.
Skip-own-filter is keyed by (path, field).

Found while testing: Typesense loses references under concurrent import

The integration test caught something the issue assumed was safe. Back-fill is
exact sequentially – a referrer imported before its referent is accepted and
resolves the moment the referent lands (pinned by a new test). But per-type
stages import into a referring and a referenced collection concurrently, and
30.2 can then lose a reference permanently: every document present, the join
finding nothing, and which edge loses varying per run. Reproduced 4 of 5 times
with two 500-document concurrent imports.

So a component built from scratch needs its indexer run twice before its
joins resolve; a second run meets referents that already exist and resolves
every reference at write time. Steady-state runs over a stable corpus are
unaffected. async_reference is still strictly right – without it those
documents would be rejected outright and, under throwOnFail: false, dropped in
silence.

This is documented as a limitation in the ADR and flagged in the
search-typesense reference; the end-to-end test indexes twice with a comment
saying why. Not ours to fix, and worth re-checking on every engine upgrade –
when it is fixed, the second run and the caveat both go.

Breaking change

The Typesense rebuild and collection-definition options take
collectionNameFor: (searchType) => string instead of name: string. A writer
now names more than its own collection: an emitted reference names its peer’s,
and a blue/green build must name the peer’s fresh collection rather than its
live alias. Migration is mechanical – name: 'x' becomes
collectionNameFor: () => 'x'.

Out of v1

Reverse joins, facets and sorting through a join, free text through a join, and
shadow collections – each with its reason in the ADR. The (path, field) facet
key already anticipates joined facets.

- add `joinable` to a reference field, valid only alongside `labelSource`,
  and `joinGraph(schema)` holding the edges it declares: the components a
  rebuild is scoped by, and the type a query path resolves to
- give a criterion an `on` path, capped at three hops, so `where` stays the
  flat conjunction of disjunctions it was and a joined criterion can sit in an
  `or` beside a local one
- compile a path into nested Typesense `$collection(…)` clauses, with the leaf
  term compiled against the target type's own declaration
- emit a joinable reference as a Typesense reference field targeting `.id`,
  `async_reference`, no cascade delete
- make the join component the unit of rebuild: open referenced-first, commit
  per component referrers-first, and fail loudly when an existing In-place
  collection lacks a declared reference
- replace the rebuild option `name` with `collectionNameFor`, so a writer can
  name a peer's versioned collection
- serve a joinable reference as `‹Target›ReferenceFilter` `@oneOf` over `in`
  and the target's own `where`, and key skip-own-filter by (path, field)

BREAKING CHANGE: the Typesense rebuild and collection-definition options
take `collectionNameFor: (searchType) => string` instead of `name: string`.
Pass `collectionNameFor: () => 'x'` where a single name was passed before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter across collections through declared joins

1 participant