Skip to content

feat(search): narrow a reference’s facet to the keys its target admits - #765

Open
ddeboer wants to merge 2 commits into
mainfrom
worktree-facet-keys
Open

feat(search): narrow a reference’s facet to the keys its target admits#765
ddeboer wants to merge 2 commits into
mainfrom
worktree-facet-keys

Conversation

@ddeboer

@ddeboer ddeboer commented Aug 22, 2026

Copy link
Copy Markdown
Member

PR 2 of 2 for the design in #763 (PR 1, key, is #764): the index-side facet policy.

A Root Type may declare facetKeys: { only: (id) => boolean } – which of its documents get a facet bucket, as a predicate over the document key. It is declared once, on the target, and inherited by every facetable reference that names the type (lookup.target, idOnly.labelSource) – the same boundary along which a reference is re-keyed and a join is drawn. Only the facet narrows: the field keeps every value, so an excluded referent still displays and still filters exactly. Facets are discovery, filters are exact.

export const place = defineSearchType({
  name: 'Place',
  key: { field: '_sameAs', pick: (candidates) => candidates.find(isGeoNames) ?? candidates.find(isCovered) },
  facetKeys: { only: isCovered },
  // …
});

What changes

@lde/search (feat)

  • RootType.facetKeys, validated (only a function; not allowed on a Reference Type).
  • physicalFields(field, schema?) gains facet: the field itself, or the ${name}_facet companion of a reference inheriting a policy, undefined for a non-facetable field – so the projection, the collection definition and the compiler keep reading one source for which engine field a facet reads.
  • The projection writes the companion in applyFacet, from the values the field stores – after the field’s own transform – so the policy applies to keys, never node IRIs (pinned by tests, as the handoff asked).
  • One conformance case: a policy facet buckets admitted keys only, labelled; a filter on an excluded value stays exact.

@lde/search-typesense (feat)

  • Collection definition: base field facet: false, companion facet: true, optional. Only the schema option resolves the policy; without it the field itself stays the facet (the projection’s reading without a schema). Not guarded: whether a policy applies cannot be told without the schema, and a schema-less build for a type with one meets Typesense’s own “could not find a facet field” on the first facet query, not a silently empty facet.
  • InPlaceRebuild refuses a declared dataset field that inherits a policy: it enumerates the indexed datasets by faceting that field, so a narrowed facet would hide the excluded datasets from the membership sweep.
  • Query compiler: facet_by reads physicalFields(field, schema).facet; the membership operator follows the field’s engine facet status, not field.facetable, so where: { locationCreated: { in: [kessel] } } compiles to := and cannot partial-match kessel/centrum.
  • Response boundary: facet_counts[].field_name is mapped back to the declared name once, before both labelLookupGroups and parseSearchResponse.
  • InPlaceRebuild: the run-open check now also fails, with the same drop instruction, when an existing collection lacks a companion its facets read.
  • End-to-end against a Typesense container: quads → projectRoots → import; an aligned place, two unaligned “Kessel”s and a place whose IRI extends Kessel’s by a path segment; every facet referencing Place shows one labelled bucket; the filter matches exactly; an in-place writer over a pre-policy collection fails at run open naming both companions.

Docs: search.md (Facet policy), search-typesense.md (collection fields, in-place check), ADR 22 (boundary sentence, facet section, two consequences).

Decisions worth a look

  • schema is optional on physicalFields, not required – the handoff left this open. Schema-optional is already the shape of this degradation in the codebase (projectDocument(node, type, schema?), buildCollectionDefinition(type, { schema? })): without one, no re-keying, no nesting, and now no companion. A required parameter would have been the one place breaking that pattern, for a function with only in-repo callers. So no ! on either commit.
  • The policy follows PR 1’s boundary exactly: a reference that names its target inherits it; one that names none, and a derived one (which reads no referent and is re-keyed by nothing), do not. A single-valued field’s companion is taken from the value it stores, not from every value the graph offered, so a facet count is always reproducible by a filter on the field.
  • Known, pre-existing hole, not widened here: nothing stops a deployment declaring a field literally named about_facet beside a policy-inheriting about – the same collision already possible with _search and _sort_<locale> companions. A reserved-suffix check would be a separate, schema-wide rule.
  • The in-place check is scoped to facet companions, as the issue asks, not to every facet field – even though a plain facetable field added after a collection was created fails the same way. Widening it would newly fail existing deployments at run open for fields they may never facet on; that generalisation (“every field the engine must have indexed a particular way”) is a follow-up if wanted.
  • n3 becomes a devDependency of search-typesense, for the end-to-end fixture: hand-written flat documents would encode the companion name by hand and miss exactly the projection ⟷ collection drift the test exists to catch. The lockfile change is that addition only; the post-release drift is left out.

Sequencing for LOL

LOL must adopt PR 1 before this: with the policy in place and the stored values still node IRIs, isCovered rejects every Drapo node and the Place facets come back empty, silently. That is the documented footgun on facetKeys. Under in-place rebuild, the first run after declaring the policy stops at run open asking for the Place-referencing collections to be dropped, once.

Fix #763

A Root Type may declare `facetKeys: { only }`, a predicate over the document
key that says which of its documents get a facet bucket. It is declared once,
on the target, and inherited by every facetable reference that names the type
(a `lookup`’s `target`, an `idOnly`’s `labelSource`) – the same boundary along
which a reference is re-keyed and a join is drawn. Only the facet narrows: the
field keeps every value, so an excluded referent still displays and still
filters exactly.

`physicalFields(field, schema?)` gains a `facet` member – the field itself, or
the `${name}_facet` companion of a reference inheriting a policy – so the
projection, the collection definition and the query compiler keep reading one
source for which engine field a facet reads. The projection writes the
companion from the values the field stores, after its own `transform`, so the
policy applies to keys and never to node IRIs. The `schema` parameter is
optional, as it already is on `projectDocument` and `buildCollectionDefinition`:
without one a field facets on its own name, the same degraded reading the
projection makes when it cannot re-key a reference.

The engine conformance suite gains one case: a policy facet buckets admitted
keys only and carries labels, and a filter on an excluded value stays exact.
…cet policy

For a facetable reference whose target declares `facetKeys`, the collection
definition declares the field itself as a plain stored value and its
`${name}_facet` companion as the facet. Only the `schema` option resolves the
policy; without it the field itself stays the facet, the reading the projection
makes without a schema. An in-place writer refuses a declared dataset field
that inherits a policy: it enumerates the indexed datasets by faceting that
field, and a narrowed facet would hide the excluded datasets from the sweep.

The query compiler facets `physicalFields(field, schema).facet` and picks the
membership operator off the field’s engine facet status rather than its
declaration: the base field is still `facetable` in the schema but no longer a
facet in the engine, so a filter on an excluded value – the case the policy
promises stays whole – would otherwise compile to the tokenised operator and
partial-match on a shared path prefix.

Typesense reports a facet under the field it faceted, so the engine maps each
`facet_counts[].field_name` back to the declared name once, at the response
boundary, before the label lookup and the response parsing – either of which
meeting the engine name would silently lose the facet’s labels or file its
counts under the wrong key.

An in-place rebuild over a collection that predates the policy fails at run
open – after the lock, before any write – naming the missing companion and the
drop that fixes it, as it does for a missing reference field: the documents
would carry the companion, the collection would not declare it, and every facet
on a reference to the policy’s type would fail until someone dropped the
collection by hand.
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.

Key a root type on a declared field, so documents, references and facets follow the canonical key

1 participant