feat(search)!: filter across collections through declared joins - #719
Open
ddeboer wants to merge 3 commits into
Open
feat(search)!: filter across collections through declared joins#719ddeboer wants to merge 3 commits into
ddeboer wants to merge 3 commits into
Conversation
- 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
force-pushed
the
feat/search-joins
branch
from
August 13, 2026 12:50
3eef0d1 to
56607a3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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/search–joinable: trueon areferencefield, valid onlyalongside
labelSource(which already asserts its values are ids in thattype’s collection).
joinGraph(schema)holds everything that follows: thecomponents a rebuild is scoped by, and what a query path resolves to. Built
eagerly by
searchSchema, so the schema-wide rules – one joinable reference pertarget, no cycles – fail at startup. A criterion gains an
onpath, capped atthree hops in the IR so a later REST surface inherits the cap;
wherestays theflat conjunction of disjunctions ADR 18 made it.
@lde/search-typesense– a joinable reference is emitted as a referencefield targeting
.id, withasync_reference: trueandcascade_delete: false(all three forced, reasons in the ADR). An
onpath compiles to nested$collection(…)clauses, the leaf compiled against the target type’sdeclaration.
InPlaceRebuildfails loudly when an existing collection lacks adeclared 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: runsopen 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 byevery field pointing at it; a non-joinable one keeps
StringFilter, so thecapability 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_referenceis still strictly right – without it thosedocuments would be rejected outright and, under
throwOnFail: false, dropped insilence.
This is documented as a limitation in the ADR and flagged in the
search-typesensereference; the end-to-end test indexes twice with a commentsaying 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:
abortbroke a partly-committed component. If a component's referrercommitted and its referent then failed,
abortdropped the referent'shalf-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.
joinableon an inline reference was silently dropped. The collectiondefinition 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 anid a reference can point at.
collectionNameForself-referenced. The natural migration fromname: 'x'iscollectionNameFor: () => 'x', which now also names everypeer – pointing the collection's reference at itself. Rejected at writer
construction with the derived form in the message.
whereinside anorcrashed on the missing clauserather than reporting anything; it now says what is wrong.
Breaking change
The Typesense rebuild and collection-definition options take
collectionNameFor: (searchType) => stringinstead ofname: string. A writernow 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'becomescollectionNameFor: () => '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)facetkey already anticipates joined facets.