From 57a13eb861c95fc9cbecea6ac471d66ca8467779 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 4 Sep 2026 11:28:16 +0200 Subject: [PATCH 1/2] fix(search-typesense): replace what a local lookup stated with the resolved document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A resolved local lookup used to spread the target’s record over the stored copy, per physical field, so a name stated here in one language survived beside the target’s name in another, and a field the target’s record lacks survived after the target dropped it - Return the resolved document instead, so a resolved entry says exactly what the projection asked for, as a plain lookup does; the stored copy still serves the identified-but-not-indexed and unidentified cases - Reword the ‘local’ contract and the docs from ‘overlays’ to ‘replaces’ - Widen the test fixture so the stored copy and the target’s record disagree per physical field --- docs/reference/search.md | 5 +-- packages/search-typesense/src/search.ts | 31 ++++++++++++------- .../test/qualified-lookup.test.ts | 20 +++++++++--- packages/search/src/schema.ts | 2 +- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/reference/search.md b/docs/reference/search.md index c10305cf..aaa005b3 100644 --- a/docs/reference/search.md +++ b/docs/reference/search.md @@ -495,8 +495,9 @@ field's [`description`](#describing-a-field). **`local: true`** on a lookup additionally stores the endpoint's own fields, as this document states them, projected through the target's own declaration. The -resolved document is overlaid on them at query time. That is what lets one field -serve both populations: +resolved document replaces them at query time – replaces rather than merges, so +a name this document stated in one language cannot survive beside the target's +name in another. That is what lets one field serve both populations: | the endpoint | what the entry carries | | ----------------------- | ---------------------------------------------------- | diff --git a/packages/search-typesense/src/search.ts b/packages/search-typesense/src/search.ts index 87ead76c..bdd551d4 100644 --- a/packages/search-typesense/src/search.ts +++ b/packages/search-typesense/src/search.ts @@ -1083,15 +1083,19 @@ function nestedValue( /** * Rebuild a {@link ReferenceStrategy.local local} lookup: the endpoint’s own - * document as this document stated it, with the **resolved** document overlaid + * document as this document stated it, **replaced** by the resolved document * where the lookup found one. * - * Both sides are the target type’s flat physical shape, so the merge is a plain - * override and the authoritative value wins field by field. Three cases fall - * out of one expression, which is the point of storing local fields - * unconditionally: + * Replaced rather than merged, because both sides are the target type’s flat + * physical shape and a logical field fans out into several physical ones: a + * name this document stated in one language would survive beside the target’s + * name in another, and a field the target’s record does not carry would + * survive after the target dropped it. The resolved document carries what the + * projection asked for, and a resolved entry says exactly that – what a plain + * lookup says too. Three cases fall out of one expression, which is the point + * of storing local fields unconditionally: * - * - identified and indexed → the target’s own record, over what was stated here; + * - identified and indexed → the target’s own record; * - identified but not indexed → what was stated here, plus the `id`, rather * than a bare IRI; * - not identified → what was stated here, with no `id` at all. @@ -1106,7 +1110,7 @@ function localLookupValue( ): SearchValue | undefined { const fetched = resolved?.via === 'lookup' ? resolved.documents : new Map(); - const merged = (Array.isArray(raw) ? raw : [raw]) + const entries = (Array.isArray(raw) ? raw : [raw]) .filter( (entry): entry is Record => typeof entry === 'object' && entry !== null, @@ -1114,11 +1118,16 @@ function localLookupValue( .map((entry) => { const id = typeof entry.id === 'string' ? entry.id : undefined; const authoritative = id === undefined ? undefined : fetched.get(id); - return authoritative === undefined - ? entry - : { ...entry, ...authoritative }; + return authoritative ?? entry; }); - return nestedValue(merged, field, target, labels, schema, resolved?.children); + return nestedValue( + entries, + field, + target, + labels, + schema, + resolved?.children, + ); } /** diff --git a/packages/search-typesense/test/qualified-lookup.test.ts b/packages/search-typesense/test/qualified-lookup.test.ts index b5a45fbb..7e2246c7 100644 --- a/packages/search-typesense/test/qualified-lookup.test.ts +++ b/packages/search-typesense/test/qualified-lookup.test.ts @@ -10,11 +10,12 @@ const person = defineSearchType({ { name: 'label', kind: 'text', - locales: ['nl'], + locales: ['nl', 'und'], output: true, searchable: { weight: 1 }, }, { name: 'birthDate', kind: 'keyword', output: true }, + { name: 'deathDate', kind: 'keyword', output: true }, ], }); @@ -77,8 +78,14 @@ const hits = { title_nl: 'Eerste', creator: [ { + // Stated in another language than the target's record, plus a + // field that record does not carry: what a merge would leak. role: 'etser', - creator: { id: 'https://p/1', label_nl: 'Rembrandt' }, + creator: { + id: 'https://p/1', + label_und: 'Rembrandt', + deathDate: '1669-10-04', + }, }, { role: 'auteur', creator: { label_nl: 'Jan Jansen' } }, ], @@ -351,7 +358,7 @@ describe('resolving a lookup inside an edge', () => { } }); - it('overlays the resolved document on what the work stated', async () => { + it('replaces what the work stated with the resolved document', async () => { const { fake } = client(); const engine = createTypesenseSearchEngine(fake.client, schema, { collections, @@ -360,7 +367,9 @@ describe('resolving a lookup inside an edge', () => { const result = await engine.search(work as never, { ...base, resolve }); const [identified] = entriesOf(result.hits[1]); - // The authoritative record wins over the name this work published. + // The authoritative record is all there is: neither the `und` name this + // work published nor the `deathDate` the target's record lacks survives, + // so the reference cannot disagree with the document it resolves to. expect(identified.creator).toEqual({ id: 'https://p/1', label: { nl: ['Rembrandt Harmenszoon van Rijn'] }, @@ -410,7 +419,8 @@ describe('resolving a lookup inside an edge', () => { expect(lookups).toHaveLength(0); expect(identified.creator).toEqual({ id: 'https://p/1', - label: { nl: ['Rembrandt'] }, + label: { und: ['Rembrandt'] }, + deathDate: '1669-10-04', }); }); }); diff --git a/packages/search/src/schema.ts b/packages/search/src/schema.ts index 9386bc64..b51a96ed 100644 --- a/packages/search/src/schema.ts +++ b/packages/search/src/schema.ts @@ -238,7 +238,7 @@ export type ReferenceStrategy = /** * Also project the target’s **own fields from this document’s frame**, so * the reference stores what the referring document states about the - * referent alongside the id – and the resolved document overlays it at + * referent alongside the id – and the resolved document replaces it at * query time rather than being the only source of it. * * What it buys is one field where there were two. A referent the graph From afefe37afdb7ec28e47a9dd84dde4dbbc4909d29 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Fri, 4 Sep 2026 11:34:01 +0200 Subject: [PATCH 2/2] docs(search): say a resolved local lookup replaces, not overlays, the stored copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reword the comments in the local lookup tests and ADR 24 that still described the overlay - Use typographer’s apostrophes in the added prose --- docs/decisions/0024-carry-data-on-a-reference-edge.md | 2 +- docs/reference/search.md | 2 +- packages/search-typesense/test/qualified-lookup.test.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/decisions/0024-carry-data-on-a-reference-edge.md b/docs/decisions/0024-carry-data-on-a-reference-edge.md index 0a8dc2e7..1bb80f9f 100644 --- a/docs/decisions/0024-carry-data-on-a-reference-edge.md +++ b/docs/decisions/0024-carry-data-on-a-reference-edge.md @@ -137,7 +137,7 @@ top-level one does (ADR 22). ### 3. A reference resolves where it can, and nests where it cannot Such a reference stores the endpoint’s locally-projected fields **always**, and -overlays the resolved document when the lookup succeeds. One field then serves +replaces it with the resolved document when the lookup succeeds. One field then serves both populations: an identified endpoint gets its id and the target’s own fields; an unidentified one gets the fields the referring document states, and no id. diff --git a/docs/reference/search.md b/docs/reference/search.md index aaa005b3..0e5e22bd 100644 --- a/docs/reference/search.md +++ b/docs/reference/search.md @@ -496,7 +496,7 @@ field's [`description`](#describing-a-field). **`local: true`** on a lookup additionally stores the endpoint's own fields, as this document states them, projected through the target's own declaration. The resolved document replaces them at query time – replaces rather than merges, so -a name this document stated in one language cannot survive beside the target's +a name this document stated in one language cannot survive beside the target’s name in another. That is what lets one field serve both populations: | the endpoint | what the entry carries | diff --git a/packages/search-typesense/test/qualified-lookup.test.ts b/packages/search-typesense/test/qualified-lookup.test.ts index 7e2246c7..8a90d129 100644 --- a/packages/search-typesense/test/qualified-lookup.test.ts +++ b/packages/search-typesense/test/qualified-lookup.test.ts @@ -78,7 +78,7 @@ const hits = { title_nl: 'Eerste', creator: [ { - // Stated in another language than the target's record, plus a + // Stated in another language than the target’s record, plus a // field that record does not carry: what a merge would leak. role: 'etser', creator: { @@ -235,7 +235,7 @@ describe('an edge that is single-valued and stores a bare id', () => { describe('an edge whose endpoint is itself multi-valued', () => { // A relation qualified once but reaching several endpoints – a joint // attribution, say. The entry then holds a LIST of endpoint documents, which - // both the id collection and the overlay have to read as one. + // both the id collection and the resolution have to read as one. const jointEdge = defineSearchType({ name: 'CreatorEdge', fields: [ @@ -311,7 +311,7 @@ describe('an edge whose endpoint is itself multi-valued', () => { }); const [entry] = entriesOf(result.hits[0]); - // The one that resolved is overlaid; the one that did not keeps what the + // The one that resolved is replaced by its document; the other keeps what the // work stated. Both stay in the entry, in order. expect(entry.creator).toEqual([ { id: 'https://p/1', label: { nl: ['Eerste'] } }, @@ -368,7 +368,7 @@ describe('resolving a lookup inside an edge', () => { const [identified] = entriesOf(result.hits[1]); // The authoritative record is all there is: neither the `und` name this - // work published nor the `deathDate` the target's record lacks survives, + // work published nor the `deathDate` the target’s record lacks survives, // so the reference cannot disagree with the document it resolves to. expect(identified.creator).toEqual({ id: 'https://p/1',