Skip to content

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

Open
ddeboer wants to merge 3 commits into
mainfrom
feat/search-joins
Open

feat(search)!: filter across collections through declared joins#719
ddeboer wants to merge 3 commits 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.

Gaps closed after review

Four issues a review of the first commit turned up, all fixed here:

  • abort broke a partly-committed component. If a component's referrer
    committed and its referent then failed, abort dropped the referent's
    half-built collection – which is exactly what the now-live referrer
    references by concrete name, so every join through the live index broke
    permanently. A partly-live component is now left alone entirely; the orphaned
    collection is the lesser evil, and both are documented.
  • joinable on an inline reference was silently dropped. The collection
    definition emits the nesting and returns before the reference declaration, so
    the join validated, compiled, and only then 400'd at the engine. Now rejected
    in validateSearchType – an inline reference carries a nested object, not an
    id a reference can point at.
  • A constant collectionNameFor self-referenced. The natural migration from
    name: 'x' is collectionNameFor: () => 'x', which now also names every
    peer – pointing the collection's reference at itself. Rejected at writer
    construction with the derived form in the message.
  • An empty joined where inside an or crashed on the missing clause
    rather than reporting anything; it now says what is wrong.

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.

@ddeboer ddeboer closed this Aug 12, 2026
@ddeboer ddeboer reopened this Aug 12, 2026
- 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.
- never abort a member of a partly-committed join component: the half-built
  collection it would drop is what the member that DID commit references by
  concrete name, so dropping it broke every join through the live index
  permanently. An orphaned collection is the lesser evil
- reject `joinable` on an inline reference: the collection definition emitted
  the nesting and silently dropped the reference, so the join validated,
  compiled and only then failed at the engine
- reject a `collectionNameFor` that gives a join target this type's own
  collection – the trap in migrating a constant from the old `name` option,
  which made the reference point the collection at itself
- name the empty joined `where` inside an `or` on its own terms; it crashed on
  the missing clause instead of reporting anything
@ddeboer
ddeboer force-pushed the feat/search-joins branch from 3eef0d1 to 56607a3 Compare August 13, 2026 12:50
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