From 5569f74b02e213464b45a0cc24ef2f3e16f23b67 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Sun, 19 Jul 2026 20:00:37 +0200 Subject: [PATCH 1/2] feat(search)!: implement inline references and class-less reference types - Split SearchType into RootType (declares a class) and ReferenceType (no class). searchSchema partitions them, so only root types key the class map and earn a collection; reference types resolve an inline ref.typeName by name. - Implement the inline strategy in projection: a referent is projected through its reference type as a nested Search Document (an array for an array reference), recursing to the depth the schema declares. A role-less inline reference is a reading device, pruned before the writer; an output one surfaces the nesting. - frameSubjects gains a depth parameter and follows the reference graph breadth-first; projectRoots derives the depth from the schema, floored at one. - searchSchema validates that every inline ref.typeName resolves to a declared reference type and rejects inline cycles, keeping framing depth bounded. - Constrain the engine port and the Typesense adapter to root types, so an indexed reference type fails to compile. BREAKING CHANGE: class is now optional on SearchType (a reference type declares none); an inline ref.typeName is resolved and cycle-checked at searchSchema construction. --- .../search-api-graphql/src/build-schema.ts | 17 +- .../search-api-graphql/src/facet-batch.ts | 4 +- .../test/build-schema.test.ts | 9 +- .../src/search-index-writer.ts | 15 +- packages/search-pipeline/src/search-stages.ts | 13 +- .../src/typed-search-document.ts | 6 +- .../test/multi-collection.integration.test.ts | 12 +- .../test/search-index-writer.test.ts | 50 +++- .../test/search-stages.test.ts | 8 +- packages/search-typesense/src/search.ts | 14 +- .../test/collection-name.test.ts | 48 +++- packages/search/README.md | 48 +++- packages/search/src/adapter.ts | 3 + packages/search/src/engine.ts | 15 +- packages/search/src/frame-by-type.ts | 67 ++++- packages/search/src/index.ts | 4 + packages/search/src/project.ts | 94 ++++++- packages/search/src/schema.ts | 250 ++++++++++++++++-- packages/search/src/testing.ts | 16 +- packages/search/test/frame-by-type.test.ts | 55 ++++ packages/search/test/project.test.ts | 219 +++++++++++++++ packages/search/test/schema.test.ts | 158 +++++++++++ packages/search/vite.config.ts | 2 +- 23 files changed, 992 insertions(+), 135 deletions(-) diff --git a/packages/search-api-graphql/src/build-schema.ts b/packages/search-api-graphql/src/build-schema.ts index 845bf3c3..8dc509ae 100644 --- a/packages/search-api-graphql/src/build-schema.ts +++ b/packages/search-api-graphql/src/build-schema.ts @@ -19,6 +19,7 @@ import { import { type Filter, type LocalizedValue, + type RootType, type SearchEngine, type SearchField, type SearchQuery, @@ -76,7 +77,7 @@ export interface SearchTypeOptions { export interface BuildGraphQLSchemaOptions { /** Optional fine-tuning per root type, keyed by the {@link SearchType} - * `name` (the logical API name, e.g. `Dataset`) — the key a consumer knows + * `name` (the logical API name, e.g. `Dataset`) – the key a consumer knows * the type by. A type without an entry gets the defaults. */ readonly types?: Readonly>; /** Output-language ordering; defaults to Accept-Language-first, `und` last. */ @@ -105,7 +106,7 @@ function screamingSnake(name: string): string { /** * Construct an executable GraphQL schema from the whole {@link SearchSchema} at - * runtime — no codegen, no SDL artifact. One root query field per + * runtime – no codegen, no SDL artifact. One root query field per * {@link SearchType} (e.g. `datasets`, `people`), each searchable in its own * way through its own output/`where`/`orderBy`/facet types, while the shared * types (`LanguageString`, buckets, filter inputs, reference types) are created @@ -193,7 +194,7 @@ export function buildGraphQLSchema( }); // Duplicate root type names cannot occur: SearchSchema is branded, so - // searchSchema() — which rejects duplicates — is the only constructor. + // searchSchema() – which rejects duplicates – is the only constructor. // One reference type per referenced shape, shared across every root type and // reused by every field (Person and CreativeWork both referencing Agent yield @@ -207,7 +208,7 @@ export function buildGraphQLSchema( const { typeName } = field.ref; if (rootTypeNames.has(typeName)) { throw new Error( - `Reference type name “${typeName}” (field “${field.name}” of “${searchType.name}”) collides with a root type of the same name; rename one — a reference does not resolve to a root type.`, + `Reference type name “${typeName}” (field “${field.name}” of “${searchType.name}”) collides with a root type of the same name; rename one – a reference does not resolve to a root type.`, ); } if (!referenceTypes.has(typeName)) { @@ -294,9 +295,9 @@ export function buildGraphQLSchema( } } - /** The root query field for one {@link SearchType}, with its derived types. */ + /** The root query field for one {@link RootType}, with its derived types. */ function rootField( - searchType: SearchType, + searchType: RootType, typeOptions: SearchTypeOptions | undefined, ): GraphQLFieldConfig { const typeName = searchType.name; @@ -479,7 +480,7 @@ export function buildGraphQLSchema( } /** - * The SDL of the built schema. Not a shipped artifact — a consumer uses it for an + * The SDL of the built schema. Not a shipped artifact – a consumer uses it for an * optional CI snapshot test over its own schema, catching accidental breaking * changes to its frozen contract (including a `buildGraphQLSchema` change in a * future version of this library silently altering it). @@ -500,7 +501,7 @@ interface QueryArgs { } /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging - * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error — a + * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a * negative offset or an unbounded page size must not reach the engine. */ function argsToQuery( args: QueryArgs, diff --git a/packages/search-api-graphql/src/facet-batch.ts b/packages/search-api-graphql/src/facet-batch.ts index 903da5c0..0274d9ff 100644 --- a/packages/search-api-graphql/src/facet-batch.ts +++ b/packages/search-api-graphql/src/facet-batch.ts @@ -1,9 +1,9 @@ import DataLoader from 'dataloader'; import type { FacetBucket, + RootType, SearchEngine, SearchQuery, - SearchType, } from '@lde/search'; /** Resolves one selected facet field to its buckets; see {@link createFacetLoader}. */ @@ -26,7 +26,7 @@ export type FacetLoader = (field: string) => Promise; */ export function createFacetLoader( engine: SearchEngine, - searchType: SearchType, + searchType: RootType, query: SearchQuery, onFacetError?: (field: string, error: unknown) => void, ): FacetLoader { diff --git a/packages/search-api-graphql/test/build-schema.test.ts b/packages/search-api-graphql/test/build-schema.test.ts index 4d1a42bf..530f319e 100644 --- a/packages/search-api-graphql/test/build-schema.test.ts +++ b/packages/search-api-graphql/test/build-schema.test.ts @@ -6,6 +6,7 @@ import { type SearchEngine, type SearchQuery, type SearchResult, + type RootType, type SearchType, } from '@lde/search'; import { buildGraphQLSchema, type SearchContext } from '../src/build-schema.js'; @@ -648,7 +649,7 @@ describe('buildGraphQLSchema', () => { expect(sdl).toMatch(/enum PersonSortField/); expect(sdl).toMatch(/input CreativeWorkWhere/); // Person has no filterable fields, so it gets no `where` arg (an empty - // input object would be invalid GraphQL) — CreativeWork keeps its own. + // input object would be invalid GraphQL) – CreativeWork keeps its own. expect(sdl).not.toMatch(/PersonWhere/); // The shared reference shape is emitted once, reused by both types. expect(sdl.match(/^type Agent /gm)).toHaveLength(1); @@ -658,7 +659,7 @@ describe('buildGraphQLSchema', () => { const searchedTypes: string[] = []; const engine: SearchEngine = { schema: searchSchema(PERSON, CREATIVE_WORK), - async search(searchType: SearchType): Promise { + async search(searchType: RootType): Promise { searchedTypes.push(searchType.class); return { total: 0, hits: [], facets: {} }; }, @@ -692,7 +693,7 @@ describe('buildGraphQLSchema', () => { name: 'author', kind: 'reference', output: true, - // Person is also a root type in this schema — same GraphQL name. + // Person is also a root type in this schema – same GraphQL name. ref: { typeName: 'Person', strategy: 'labelOnly' }, }, ], @@ -713,7 +714,7 @@ describe('buildGraphQLSchema', () => { expect(() => searchSchema(PERSON, alsoPerson)).toThrow( /Duplicate search type name “Person”/, ); - // @ts-expect-error — a hand-built map is not a (branded) SearchSchema + // @ts-expect-error – a hand-built map is not a (branded) SearchSchema void (() => buildGraphQLSchema(new Map([[PERSON.class, PERSON]]))); }); diff --git a/packages/search-pipeline/src/search-index-writer.ts b/packages/search-pipeline/src/search-index-writer.ts index cb6f42a7..1f5b9584 100644 --- a/packages/search-pipeline/src/search-index-writer.ts +++ b/packages/search-pipeline/src/search-index-writer.ts @@ -6,21 +6,22 @@ import { type RunWriter, type Writer, } from '@lde/pipeline'; -import type { SearchDocument, SearchSchema, SearchType } from '@lde/search'; +import type { RootType, SearchDocument, SearchSchema } from '@lde/search'; import type { TypedSearchDocument } from './typed-search-document.js'; /** Options for {@link searchIndexWriter}. */ export interface SearchIndexWriterOptions { /** - * The declarative schema: one {@link SearchType} per root type. The writer - * opens one engine run per type in it and routes each document to its type’s - * run. Must be the same schema the stages project through. + * The declarative schema: one {@link RootType} per collection. The writer + * opens one engine run per Root Type in it and routes each document to its + * type’s run. Reference Types are absent from `schema.values()`, so none ever + * earns a run. Must be the same schema the stages project through. */ schema: SearchSchema; /** * The engine writer that owns a given root type’s collection – e.g. a * `@lde/search-typesense` `BlueGreenRebuild` bound to that type and the - * collection name it keeps its alias on. Called once per {@link SearchType} + * collection name it keeps its alias on. Called once per {@link RootType} * in the schema when the writer is built. * * A single-collection deployment returns one writer for its one type; a @@ -30,7 +31,7 @@ export interface SearchIndexWriterOptions { * own collection, alias and cross-pod lock. The per-collection fan-out is this * writer’s job. */ - writerFor: (searchType: SearchType) => Writer; + writerFor: (searchType: RootType) => Writer; } /** @@ -113,7 +114,7 @@ export function searchIndexWriter( string, { queue: AsyncQueue; done: Promise } >(); - const laneFor = (searchType: SearchType) => { + const laneFor = (searchType: RootType) => { let lane = lanes.get(searchType.class); if (lane === undefined) { const run = runs.get(searchType.class); diff --git a/packages/search-pipeline/src/search-stages.ts b/packages/search-pipeline/src/search-stages.ts index 895a7af2..c97ca585 100644 --- a/packages/search-pipeline/src/search-stages.ts +++ b/packages/search-pipeline/src/search-stages.ts @@ -4,18 +4,19 @@ import { type ItemSelector, type StageReaders, } from '@lde/pipeline'; -import { projectRoots, type SearchSchema, type SearchType } from '@lde/search'; +import { projectRoots, type RootType, type SearchSchema } from '@lde/search'; import type { TypedSearchDocument } from './typed-search-document.js'; /** One root type’s stage in a search pipeline. */ export interface SearchStageType { /** - * The {@link SearchType} this stage projects. Must belong to + * The {@link RootType} this stage projects. Must belong to * {@link SearchStagesOptions.schema} (matched by `class`); the stage projects * with the schema’s own declaration object, so `assertTypeInSchema`’s identity - * check inside {@link projectRoots} always holds. + * check inside {@link projectRoots} always holds. A Reference Type is never a + * stage: it is reached only through an inline reference, never selected. */ - searchType: SearchType; + searchType: RootType; /** * The selector variable that binds this type’s roots – the CONSTRUCT subject * the batch is complete for. Must **not** be `dataset`: `?dataset` is @@ -125,7 +126,7 @@ export function searchStages( /** * An {@link ItemSelector} that selects every instance of a root type’s source * class: `SELECT ?‹rootVariable› WHERE { ?‹rootVariable› a }`. A - * convenience for the **object grain**, where {@link SearchType.class} really is + * convenience for the **object grain**, where {@link RootType.class} really is * the source class – **not** a default: root selection is a deployment concern, * and three of the Dataset Register’s four catalog types have no source class at * all (their entry point – “registered, newest registration, has a title” – is a @@ -136,7 +137,7 @@ export function searchStages( * SPARQL reader). */ export function selectByClass( - searchType: SearchType, + searchType: RootType, rootVariable = 'root', ): ItemSelector { return new SparqlItemSelector({ diff --git a/packages/search-pipeline/src/typed-search-document.ts b/packages/search-pipeline/src/typed-search-document.ts index 6d92f399..53d3cd47 100644 --- a/packages/search-pipeline/src/typed-search-document.ts +++ b/packages/search-pipeline/src/typed-search-document.ts @@ -1,7 +1,7 @@ -import type { SearchDocument, SearchType } from '@lde/search'; +import type { RootType, SearchDocument } from '@lde/search'; /** - * A projected {@link SearchDocument} paired with the {@link SearchType} it was + * A projected {@link SearchDocument} paired with the {@link RootType} it was * projected from. * * A search pipeline is N per-type stages writing to one terminal, and @@ -18,6 +18,6 @@ import type { SearchDocument, SearchType } from '@lde/search'; * ([ADR 13](https://github.com/ldelements/lde/blob/main/docs/decisions/0013-project-inside-the-batch-per-root-type.md)). */ export interface TypedSearchDocument { - readonly searchType: SearchType; + readonly searchType: RootType; readonly document: SearchDocument; } diff --git a/packages/search-pipeline/test/multi-collection.integration.test.ts b/packages/search-pipeline/test/multi-collection.integration.test.ts index 601d9310..1cf3ceab 100644 --- a/packages/search-pipeline/test/multi-collection.integration.test.ts +++ b/packages/search-pipeline/test/multi-collection.integration.test.ts @@ -7,8 +7,8 @@ import type { RunContext, Writer } from '@lde/pipeline'; import { projectRoots, searchSchema, + type RootType, type SearchDocument, - type SearchType, } from '@lde/search'; import { BlueGreenRebuild } from '@lde/search-typesense'; import { searchIndexWriter } from '../src/search-index-writer.js'; @@ -50,8 +50,8 @@ const schema = searchSchema( }, ); -const datasetType = schema.get(DATASET) as SearchType; -const organizationType = schema.get(ORGANIZATION) as SearchType; +const datasetType = schema.get(DATASET) as RootType; +const organizationType = schema.get(ORGANIZATION) as RootType; const COLLECTION: Record = { [DATASET]: 'datasets', @@ -81,7 +81,7 @@ function mixedQuads(): Quad[] { async function paired( quads: readonly Quad[], roots: readonly string[], - searchType: SearchType, + searchType: RootType, ): Promise { const documents: TypedSearchDocument[] = []; for await (const document of projectRoots(quads, roots, schema, searchType)) { @@ -113,7 +113,7 @@ function makeRunContext(): RunContext { function blueGreenFor( client: Client, -): (searchType: SearchType) => Writer { +): (searchType: RootType) => Writer { return (searchType) => new BlueGreenRebuild(client, searchType, { name: COLLECTION[searchType.class], @@ -201,7 +201,7 @@ describe('searchIndexWriter over multiple Typesense collections', () => { it('lets the datasets index go live even when a label collection fails to commit', async () => { // The Organization rebuild fails at commit, before its alias swaps. The // datasets index must still go live; the failure must surface. - const failing: (searchType: SearchType) => Writer = ( + const failing: (searchType: RootType) => Writer = ( searchType, ) => { const real = blueGreenFor(client)(searchType); diff --git a/packages/search-pipeline/test/search-index-writer.test.ts b/packages/search-pipeline/test/search-index-writer.test.ts index ad76f890..f58d67dc 100644 --- a/packages/search-pipeline/test/search-index-writer.test.ts +++ b/packages/search-pipeline/test/search-index-writer.test.ts @@ -3,6 +3,7 @@ import { Dataset } from '@lde/dataset'; import type { RunContext, RunWriter, Writer } from '@lde/pipeline'; import { searchSchema, + type RootType, type SearchDocument, type SearchType, } from '@lde/search'; @@ -30,7 +31,7 @@ const schema = searchSchema( ); /** The schema’s own declaration object for a class (identity matters). */ -function typeOf(classIri: string): SearchType { +function typeOf(classIri: string): RootType { const searchType = schema.get(classIri); if (searchType === undefined) { throw new Error(`no such type: ${classIri}`); @@ -66,7 +67,7 @@ function makeRunContext(): RunContext { /** One fake engine collection: records every lifecycle call routed to it. */ interface FakeCollection { - readonly searchType: SearchType; + readonly searchType: RootType; readonly writes: { dataset: Dataset; documents: SearchDocument[] }[]; readonly flushes: { dataset: Dataset; outcome: string }[]; readonly resets: Dataset[]; @@ -91,7 +92,7 @@ function makeFleet( const collections = new Map(); const commitOverride = overrides.commit; - const writerFor = (searchType: SearchType): Writer => { + const writerFor = (searchType: RootType): Writer => { const collection: FakeCollection = { searchType, writes: [], @@ -194,6 +195,47 @@ describe('searchIndexWriter', () => { } }); + it('opens no run for a reference type – only root types get a writer', async () => { + // A schema with a Root Type and a Reference Type (reached by an inline + // reference). The Reference Type is absent from `schema.values()`, so the + // writer never asks for one: no collection, no run. + const withReference = searchSchema( + { + name: 'Dataset', + class: 'https://example.org/Dataset', + fields: [ + { + name: 'registration', + kind: 'reference', + output: true, + path: 'urn:lde:Dataset/registration', + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + }, + { + name: 'Registration', + fields: [ + { + name: 'dateRead', + kind: 'date', + path: 'https://schema.org/dateRead', + }, + ], + }, + ); + const built: string[] = []; + const writerFor = (searchType: RootType): Writer => { + built.push(searchType.name); + return { + openRun: () => Promise.resolve({} as RunWriter), + }; + }; + searchIndexWriter({ schema: withReference, writerFor }); + + expect(built).toEqual(['Dataset']); + }); + it('streams every document of one write straight to its run', async () => { const fleet = makeFleet(); const run = await openRun(fleet); @@ -413,7 +455,7 @@ describe('searchIndexWriter', () => { const opened: SearchType[] = []; const aborted: SearchType[] = []; const failure = new Error('lock held'); - const writerFor = (searchType: SearchType): Writer => ({ + const writerFor = (searchType: RootType): Writer => ({ openRun: async () => { // The second type to open fails; the first must be rolled back. if (opened.length === 1) { diff --git a/packages/search-pipeline/test/search-stages.test.ts b/packages/search-pipeline/test/search-stages.test.ts index f6049880..27350024 100644 --- a/packages/search-pipeline/test/search-stages.test.ts +++ b/packages/search-pipeline/test/search-stages.test.ts @@ -10,7 +10,7 @@ import { type Reader, type VariableBindings, } from '@lde/pipeline'; -import { projectRoots, searchSchema, type SearchType } from '@lde/search'; +import { projectRoots, searchSchema, type RootType } from '@lde/search'; import { searchStages, selectByClass } from '../src/search-stages.js'; import type { TypedSearchDocument } from '../src/typed-search-document.js'; @@ -24,7 +24,7 @@ const schema = searchSchema({ class: PERSON, fields: [{ name: 'name', kind: 'keyword', path: NAME, output: true }], }); -const person = schema.get(PERSON) as SearchType; +const person = schema.get(PERSON) as RootType; const dataset = new Dataset({ iri: new URL('http://example.org/dataset/1'), @@ -112,7 +112,7 @@ describe('searchStages', () => { it('projects with the schema’s own declaration even when handed a class-equal lookalike', async () => { // A reconstructed object with the same class – `assertTypeInSchema` is an // identity check, so searchStages must re-resolve to the schema’s own object. - const lookalike: SearchType = { + const lookalike: RootType = { name: 'Person', class: PERSON, fields: [{ name: 'name', kind: 'keyword', path: NAME, output: true }], @@ -142,7 +142,7 @@ describe('searchStages', () => { }); it('throws when a type is not in the schema', () => { - const foreign: SearchType = { + const foreign: RootType = { name: 'Ghost', class: 'https://example.org/Ghost', fields: [], diff --git a/packages/search-typesense/src/search.ts b/packages/search-typesense/src/search.ts index 35be53b6..7beaefe3 100644 --- a/packages/search-typesense/src/search.ts +++ b/packages/search-typesense/src/search.ts @@ -6,6 +6,8 @@ import { type Reference, type ReferenceField, type ResultDocument, + type RootType, + type RootTypeOf, type SearchField, type SearchHit, type SearchEngine, @@ -83,7 +85,7 @@ export interface TypesenseSearchEngine< * exists), never an input. Throws for a type outside this engine’s schema, * like every other entry point. */ - collectionNameFor(searchType: Types[number]): string; + collectionNameFor(searchType: RootTypeOf): string; } /** @@ -160,7 +162,7 @@ export function createTypesenseSearchEngine< referenceFields(searchType) .filter((field) => field.labelSource !== undefined) .map((field) => { - const source = typesByName.get(field.labelSource!) as SearchType; + const source = typesByName.get(field.labelSource!) as RootType; const labelField = labelFieldOf(source) as TextField; return [ field.name, @@ -286,7 +288,7 @@ export function createTypesenseSearchEngine< // cannot leave an unhandled rejection behind if the search itself fails. // `undefined` when the cache is off or the type has no label sources. function startCachedLabels( - searchType: SearchType, + searchType: RootType, ): Promise> | undefined { const sources = distinctLabelSources.get(searchType.class); if ( @@ -325,12 +327,12 @@ export function createTypesenseSearchEngine< const engine: TypesenseSearchEngine = { schema, - collectionNameFor(searchType: SearchType): string { + collectionNameFor(searchType: RootType): string { assertTypeInSchema(schema, searchType); return collections.get(searchType.class) as string; }, async search( - searchType: SearchType, + searchType: RootType, query: SearchQuery, ): Promise { // The port contract: a type outside the bound schema and a structurally @@ -354,7 +356,7 @@ export function createTypesenseSearchEngine< return parseSearchResponse(response, searchType, labels); }, async searchFacets( - searchType: SearchType, + searchType: RootType, queries: readonly SearchQuery[], ): Promise { assertTypeInSchema(schema, searchType); diff --git a/packages/search-typesense/test/collection-name.test.ts b/packages/search-typesense/test/collection-name.test.ts index 4ecf4a1f..95a83240 100644 --- a/packages/search-typesense/test/collection-name.test.ts +++ b/packages/search-typesense/test/collection-name.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { Client } from 'typesense'; -import { searchSchema, type SearchQuery, type SearchType } from '@lde/search'; +import { + defineSearchType, + searchSchema, + type RootType, + type SearchQuery, +} from '@lde/search'; import { deriveCollectionName } from '../src/collection-name.js'; import { buildCollectionDefinition } from '../src/collection-definition.js'; import { createTypesenseSearchEngine } from '../src/search.js'; @@ -8,7 +13,7 @@ import { BlueGreenRebuild } from '../src/blue-green-rebuild.js'; import { InPlaceRebuild } from '../src/in-place-rebuild.js'; import { fakeTypesenseClient, labelLookup } from './fake-typesense-client.js'; -const typeNamed = (name: string): SearchType => ({ +const typeNamed = (name: string): RootType => ({ name, class: `https://example.org/${name}`, fields: [{ name: 'title', kind: 'keyword' }], @@ -164,10 +169,45 @@ describe('the writers and the engine agree on a type’s collection', () => { /is not in this engine’s schema/, ); }); + + it('gives a reference type no collection – only root types are indexed', () => { + const registration = defineSearchType({ + name: 'Registration', + fields: [ + { name: 'dateRead', kind: 'date', path: 'https://schema.org/dateRead' }, + ], + }); + const dataset = defineSearchType({ + name: 'Dataset', + class: 'https://example.org/Dataset', + fields: [ + { + name: 'registration', + kind: 'reference', + output: true, + path: 'urn:lde:Dataset/registration', + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + }); + const engine = createTypesenseSearchEngine( + noClient, + searchSchema(dataset, registration), + ); + // The Root Type has a collection… + expect(engine.collectionNameFor(dataset)).toBe('datasets'); + // …but the Reference Type is not indexed: asking for its collection is a + // compile error (it is absent from the engine’s served types) and rejects + // at run time too. + expect(() => + // @ts-expect-error a reference type has no collection. + engine.collectionNameFor(registration), + ).toThrow(/is not in this engine’s schema/); + }); }); describe('label sources', () => { - const organization: SearchType = { + const organization: RootType = { name: 'Organization', class: 'http://xmlns.com/foaf/0.1/Organization', fields: [ @@ -180,7 +220,7 @@ describe('label sources', () => { }, ], }; - const dataset: SearchType = { + const dataset: RootType = { name: 'Dataset', class: 'http://www.w3.org/ns/dcat#Dataset', fields: [ diff --git a/packages/search/README.md b/packages/search/README.md index a7a0a84d..02fe2c10 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -71,11 +71,22 @@ Exports are stratified by audience: The model has three levels, with analogues in SHACL ([one possible source](#why-a-declarative-model)) and GraphQL (one of the surfaces): -| Term | What it is | SHACL | GraphQL | -| -------------- | ---------------------------------------------------------------------------------------------------------------- | -------------- | ----------- | -| `SearchField` | One queryable field: a `kind`, the IR `path` it projects from, and the capability flags it opts into | property shape | field | -| `SearchType` | One root type’s complete declaration: its logical API `name`, its `class` IRI and its fields (incl. derived) | NodeShape | object type | -| `SearchSchema` | The whole search declaration: every `SearchType`, keyed by `class` IRI – build one with `searchSchema(...types)` | shapes graph | schema | +| Term | What it is | SHACL | GraphQL | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ----------- | +| `SearchField` | One queryable field: a `kind`, the IR `path` it projects from, and the capability flags it opts into | property shape | field | +| `SearchType` | One type’s complete declaration: its logical API `name` and fields (incl. derived). A **Root Type** declares a `class` (indexed, keys the schema); a **Reference Type** declares none (reached only through an inline reference) | NodeShape | object type | +| `SearchSchema` | The whole search declaration: every Root Type, keyed by `class` IRI, plus the Reference Types – build one with `searchSchema(...types)` | shapes graph | schema | + +A `SearchType` is a **Root Type** or a **Reference Type**, told apart by one +absence: a Root Type declares a `class`, so roots are selected for it and a +writer opens a collection for it; a Reference Type declares none, so it is +never selected, framed by type or indexed – its identity is its name, and its +type comes from the edge that points at it. `searchSchema` partitions its +arguments accordingly: Root Types key the class map (`schema.values()` yields +only them, so no writer ever opens a collection for a Reference Type), Reference +Types go into a name index an inline `ref.typeName` resolves against. The +absence is load-bearing, and enforced at the type level – an indexed Reference +Type fails to compile ([ADR 11](../../docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md)). `projectRoots` and the engine port each execute one `SearchType` at a time – projection over the roots the pipeline selector supplied for that type; the @@ -208,10 +219,29 @@ directly. | `date` | `range` (inclusive) | yes | yes | ISO 8601 string (surface) | | `boolean` | `is` | yes | – | boolean (absent = false) | -A `reference` carries `labelOnly` today (id + display label); the `idOnly` and -`inline` strategies are forward declarations. References are object-shaped from -day one so that `inline` can later **add** fields to a reference type without -breaking clients. +A `reference` carries one of two strategies today: `labelOnly` (id + display +label, resolved at query time from a label source) and `inline` (the referent’s +own projected fields, carried inline). `idOnly` stays a forward declaration. + +An **inline reference** resolves `ref.typeName` to a declared **Reference Type** +and projects the referent through it – a nested `SearchDocument`, or an array +for an `array` reference. Roles decide whether the nesting surfaces, so the same +construct serves two jobs (see +[ADR 11](../../docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md)): + +- a **reading device** declares no role, so it is an internal field: projected + so a later `derive` can select and flatten a value a `path` cannot address (a + qualified hop), then pruned before the writer – nothing nested reaches the + engine or the API; +- an **API device** declares `output`, deliberately surfacing the nested + Reference Type. + +So RDF depth and API shape stay independent: inline as deep as the source +demands, expose exactly the flat fields you want. Framing follows the inline +reference graph to the depth the schema declares (`Dataset → Subset → +Measurement` is two hops), and `searchSchema` rejects inline cycles – the one +way that depth could be unbounded – so it stays a bounded property of the +declaration. A reference resolves its label from a **label source**: `labelSource` names the `SearchType` whose collection holds the referenced entities. The named diff --git a/packages/search/src/adapter.ts b/packages/search/src/adapter.ts index 2f277062..0a123e3d 100644 --- a/packages/search/src/adapter.ts +++ b/packages/search/src/adapter.ts @@ -18,7 +18,10 @@ export { sortableFields, outputFields, isInternalField, + isInlineReference, referenceFields, + referenceTypeNamed, + inlineFramingDepth, fieldNamed, labelFieldOf, isRangeFacet, diff --git a/packages/search/src/engine.ts b/packages/search/src/engine.ts index 279ab7bf..3b69e63f 100644 --- a/packages/search/src/engine.ts +++ b/packages/search/src/engine.ts @@ -1,5 +1,10 @@ import type { SearchQuery } from './query.js'; -import type { SearchSchema, SearchType } from './schema.js'; +import type { + RootType, + RootTypeOf, + SearchSchema, + SearchType, +} from './schema.js'; /** * The engine port: the boundary a concrete engine adapter (e.g. the engine @@ -37,7 +42,7 @@ export interface SearchEngine< /** The declaration this engine serves – exposed so a surface can route and * a caller can enumerate the searchable types. */ readonly schema: SearchSchema; - search( + search>( searchType: T, query: SearchQuery, ): Promise, OutputFieldsOf>>; @@ -58,7 +63,7 @@ export interface SearchEngine< * transport itself). The port contract holds for every query in the batch; * an empty `queries` resolves to `[]` without touching the engine. */ - searchFacets( + searchFacets>( searchType: T, queries: readonly SearchQuery[], ): Promise>[]>; @@ -100,7 +105,7 @@ export type FacetMap = Readonly< * `: SearchType` annotation degrades to `string` (its field names are not * statically known). */ -export type FacetFieldsOf = SearchType extends Type +export type FacetFieldsOf = RootType extends Type ? string : Extract['name']; @@ -109,7 +114,7 @@ export type FacetFieldsOf = SearchType extends Type * can hold. Like {@link FacetFieldsOf}, degrades to `string` for a widened * declaration. */ -export type OutputFieldsOf = SearchType extends Type +export type OutputFieldsOf = RootType extends Type ? string : Extract['name']; diff --git a/packages/search/src/frame-by-type.ts b/packages/search/src/frame-by-type.ts index ad1fde40..fb10accd 100644 --- a/packages/search/src/frame-by-type.ts +++ b/packages/search/src/frame-by-type.ts @@ -52,11 +52,17 @@ export function buildSubjectIndex(quads: Iterable): SubjectIndex { /** * Frame each of the given `roots` from a prebuilt {@link SubjectIndex} into one - * JSON-LD node. Each root subject’s own triples plus the one-hop nodes it - * references (e.g. nested publisher/distribution resources) are framed one - * subject at a time, so beyond the shared subject index only a single subgraph - * is held (whole-graph `jsonld.frame()` is ~O(N²)). The frame carries no - * `@context`, so framed keys are full predicate IRIs. + * JSON-LD node. Each root subject’s own triples plus the nodes it references – + * to `depth` hops (e.g. nested publisher/distribution resources at one hop, an + * inline reference’s referents deeper) – are framed one subject at a time, so + * beyond the shared subject index only a single subgraph is held (whole-graph + * `jsonld.frame()` is ~O(N²)). The frame carries no `@context`, so framed keys + * are full predicate IRIs. + * + * `depth` is the number of reference hops to embed; it defaults to one (the + * long-standing single-hop embed) and comes from the schema for a type with + * inline references (`inlineFramingDepth`). Bounded per batch, so the O(N²) + * framing cost applies to a batch of roots, not the graph (ADR 12). * * The roots are supplied explicitly rather than discovered from `rdf:type`: the * caller ({@link projectRoots}, from the pipeline selector) already holds them @@ -65,18 +71,11 @@ export function buildSubjectIndex(quads: Iterable): SubjectIndex { export async function* frameSubjects( index: SubjectIndex, roots: readonly string[], + depth = 1, ): AsyncIterable { const { bySubject } = index; for (const rootIri of roots) { - const owned = bySubject.get(rootIri) ?? []; - const referenced = owned - .filter( - (quad) => - quad.object.termType === 'NamedNode' || - quad.object.termType === 'BlankNode', - ) - .flatMap((quad) => bySubject.get(quad.object.value) ?? []); - const subgraph = [...owned, ...referenced]; + const subgraph = collectSubgraph(bySubject, rootIri, depth); const expanded = await jsonld.fromRDF(subgraph); // Frame for THIS specific root subject by its `@id`. The subgraph embeds the // root’s one-hop references, and such a referent can itself be one of the @@ -95,3 +94,43 @@ export async function* frameSubjects( } } } + +/** + * Collect a root subject’s triples plus those of every subject it references + * within `depth` hops, breadth-first. Each subject is visited once (so a + * reference cycle in the data terminates and no subject’s triples are + * duplicated); `jsonld.frame` embeds whatever the subgraph reaches. Bounded by + * `depth`, so only the reachable subgraph of one root is materialized at a time. + */ +function collectSubgraph( + bySubject: ReadonlyMap, + rootIri: string, + depth: number, +): Quad[] { + const collected: Quad[] = []; + const visited = new Set(); + let frontier = [rootIri]; + for (let hop = 0; hop <= depth; hop++) { + const next: string[] = []; + for (const subject of frontier) { + if (visited.has(subject)) { + continue; + } + visited.add(subject); + const owned = bySubject.get(subject) ?? []; + collected.push(...owned); + if (hop < depth) { + for (const quad of owned) { + if ( + quad.object.termType === 'NamedNode' || + quad.object.termType === 'BlankNode' + ) { + next.push(quad.object.value); + } + } + } + } + frontier = next; + } + return collected; +} diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts index 787e503f..0b01b4a8 100644 --- a/packages/search/src/index.ts +++ b/packages/search/src/index.ts @@ -29,6 +29,10 @@ export type { NumericField, BooleanField, SearchType, + SearchTypeBase, + RootType, + ReferenceType, + RootTypeOf, SearchTypeIssue, SearchSchema, FacetRange, diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index 719a9531..7981dcc7 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -8,11 +8,15 @@ import { import { assertTypeInSchema, displayFieldName, + inlineFramingDepth, isInternalField, + isInlineReference, isoToUnixSeconds, physicalFields, + referenceTypeNamed, type KeywordField, type ReferenceField, + type RootType, type SearchField, type SearchSchema, type SearchType, @@ -37,17 +41,9 @@ export type SearchDocument = { id: string } & Record; export function projectDocument( node: FramedNode, searchType: SearchType, + schema?: SearchSchema, ): SearchDocument { - const id = node['@id']; - if (typeof id !== 'string') { - throw new Error( - `Cannot project a ${searchType.class} node without an @id: every search document needs a stable key, and an empty one would collide with other keyless nodes.`, - ); - } - const document: SearchDocument = { id }; - for (const field of searchType.fields) { - applyField(document, node, field); - } + const document = projectFields(node, searchType, schema); // Prune internal fields: they were projected only so the derives above could // read them (a structural memo shared across every derive that needs the // value), and must not reach the writer or the collection definition. @@ -59,6 +55,29 @@ export function projectDocument( return document; } +/** Apply every field of `searchType` to a fresh document, without pruning – the + * shared core of {@link projectDocument} (which then prunes internal fields) + * and inline-referent projection (which keeps them, because the referent’s + * fields exist to be read by the declaring type’s derives or surfaced whole, + * and whether it surfaces is decided by the *declaring* field’s roles). */ +function projectFields( + node: FramedNode, + searchType: SearchType, + schema: SearchSchema | undefined, +): SearchDocument { + const id = node['@id']; + if (typeof id !== 'string') { + throw new Error( + `Cannot project a “${searchType.name}” node without an @id: every search document needs a stable key, and an empty one would collide with other keyless nodes.`, + ); + } + const document: SearchDocument = { id }; + for (const field of searchType.fields) { + applyField(document, node, field, schema); + } + return document; +} + /** * Project a single type over a known set of `roots` – the per-type, roots-given * projection. The roots are supplied by the caller (the pipeline selector) @@ -76,7 +95,7 @@ export async function* projectRoots( quads: Iterable, roots: readonly string[], schema: SearchSchema, - searchType: SearchType, + searchType: RootType, ): AsyncIterable { assertTypeInSchema(schema, searchType); const index = buildSubjectIndex(quads); @@ -84,8 +103,9 @@ export async function* projectRoots( // non-`DISTINCT` `SELECT` over a one-to-many join yields the same subject per // matched row – and a repeated root would otherwise frame and emit a // duplicate document under the same `id`. - for await (const node of frameSubjects(index, [...new Set(roots)])) { - yield projectDocument(node, searchType); + const depth = inlineFramingDepth(schema, searchType); + for await (const node of frameSubjects(index, [...new Set(roots)], depth)) { + yield projectDocument(node, searchType, schema); } } @@ -93,6 +113,7 @@ function applyField( document: SearchDocument, node: FramedNode, field: SearchField, + schema: SearchSchema | undefined, ): void { if (field.derive !== undefined) { const value = field.derive(document); @@ -106,6 +127,16 @@ function applyField( // Neither path nor derive: populated outside the projection, if at all. return; } + if (isInlineReference(field)) { + // An inline reference is a nested structure, not a bare IRI: it can only be + // projected with the schema that declares its reference type. Without one, + // project nothing rather than fall through and emit the referent IRIs under + // the field name (the wrong shape). + if (schema !== undefined) { + applyInlineReference(document, node, path, field, schema); + } + return; + } switch (field.kind) { case 'text': return applyText(document, langValuesOf(node, path), field); @@ -222,6 +253,43 @@ function applyFacet( } } +/** + * Project an inline reference: the referent node(s) embedded under `path` are + * each projected through the reference’s {@link ReferenceType} (its fields’ + * paths relative to the referent node) and attached under the field’s name – a + * nested {@link SearchDocument} for a single reference, an array for an + * `array` one. The nested fields keep their internal fields un-pruned: they + * exist to be read by the declaring type’s derives (the *reading device*) or + * surfaced whole (the *API device*), and whether the nesting reaches the writer + * is decided by *this* field’s roles – an internal inline reference is pruned in + * full by {@link projectDocument}. Recurses through `schema`, so an inline + * reference may itself carry further inline references to the schema’s declared + * depth. The referent type is guaranteed declared by {@link searchSchema}. + */ +function applyInlineReference( + document: SearchDocument, + node: FramedNode, + path: string, + field: ReferenceField & { readonly ref: { readonly typeName: string } }, + schema: SearchSchema, +): void { + // Resolves for a schema that declares the referent (always so for the schema a + // type is projected through); a type framed against a foreign schema that + // omits it simply contributes no nesting. + const referenceType = referenceTypeNamed(schema, field.ref.typeName); + if (referenceType === undefined) { + return; + } + const referents = valuesOf(node, path) + .filter(isObject) + .filter((referent) => typeof referent['@id'] === 'string') + .map((referent) => projectFields(referent, referenceType, schema)); + if (referents.length === 0) { + return; + } + document[field.name] = field.array === true ? referents : referents[0]; +} + // --- Framed-IR readers: read a declared `path` off the framed node. Internal // to projection – a `derive` reads the projected document, never the node, so // `path` stays the whole statement of what the projection reads from the graph. diff --git a/packages/search/src/schema.ts b/packages/search/src/schema.ts index 09298f0a..5342b84b 100644 --- a/packages/search/src/schema.ts +++ b/packages/search/src/schema.ts @@ -232,27 +232,66 @@ export interface FacetRange { readonly max?: number; } -/** - * One root type’s complete search declaration: its logical API `name`, the - * `class` IRI its documents are instances of (the RDF class), and the queryable - * `fields` (including {@link SearchField.derive derived} ones). A SHACL - * generator can emit one per NodeShape (`name`←`sh:name`/local name, - * `class`←`sh:targetClass`, `fields`←its property shapes), but that is a source, - * not a requirement. - */ -export interface SearchType { +/** The declaration members every {@link SearchType} shares. */ +export interface SearchTypeBase { /** Logical API name (PascalCase, e.g. `Dataset`) – names the type in the API * surfaces (GraphQL type names, a REST path), the way each field’s * {@link SearchField.name} names that field. Deliberately declared rather * than derived from the `class` IRI, so re-modelling the vocabulary cannot * silently rename the public contract. */ readonly name: string; + readonly fields: readonly SearchField[]; +} + +/** + * A **Root Type**: a {@link SearchType} that is indexed. It declares a `class`, + * roots are selected for it, a Writer owns a collection for it, and the + * {@link SearchSchema} is keyed by it. A SHACL generator can emit one per + * NodeShape (`name`←`sh:name`/local name, `class`←`sh:targetClass`, + * `fields`←its property shapes), but that is a source, not a requirement. + */ +export interface RootType extends SearchTypeBase { /** The RDF class IRI its documents are instances of (`sh:targetClass`); the - * key a {@link SearchSchema} maps this type under. */ + * key a {@link SearchSchema} maps this type under. Its presence is what makes + * a type a Root Type – and so what gives it a collection. */ readonly class: string; - readonly fields: readonly SearchField[]; } +/** + * A **Reference Type**: a {@link SearchType} reached only through an + * {@link ReferenceField.ref inline reference}. It declares **no `class`** – + * never selected, never framed by type, never indexed; its identity is its + * `name`, and its type comes from the edge that points at it, not from the node. + * The absence is load-bearing, not stylistic: a `class` would put it in the + * {@link SearchSchema} map and silently earn it a collection nobody asked for. + * The shape an inline reference carries – see + * [ADR 11](../../docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md). + */ +export interface ReferenceType extends SearchTypeBase { + /** A Reference Type declares no class; declaring one makes it a + * {@link RootType}. Typed as `never` so the two shapes discriminate the way + * {@link SearchField} discriminates by `kind`: an indexed Reference Type + * fails to compile, not at run time. */ + readonly class?: never; +} + +/** + * One type’s complete search declaration: its logical API `name`, the queryable + * `fields` (including {@link SearchField.derive derived} ones), and – for a + * {@link RootType} – the RDF `class` its documents are instances of. Either a + * Root Type or a {@link ReferenceType}; the absence of a `class` tells them + * apart. + */ +export type SearchType = RootType | ReferenceType; + +/** The Root Types among a declared tuple – the ones a {@link SearchSchema} + * keys and a Writer opens a collection for. Reference Types are excluded, so + * no consumer that iterates `schema.values()` ever meets one. */ +export type RootTypeOf = Extract< + Types[number], + { readonly class: string } +>; + /** * Declare a {@link SearchType}, capturing it as a literal: the `const` type * parameter preserves the field names and capability flags that the type-level @@ -268,11 +307,16 @@ export function defineSearchType( } /** - * The complete search declaration of a deployment: every root {@link SearchType}, - * keyed by its `class` IRI. Build one with {@link searchSchema}, which captures - * the declared types as a literal tuple (`Types`), so schema-bound consumers - * (the engine port) can type their per-type behaviour off it. A plain + * The complete search declaration of a deployment: every {@link RootType}, + * keyed by its `class` IRI, plus the {@link ReferenceType}s an inline reference + * resolves against. Build one with {@link searchSchema}, which captures the + * declared types as a literal tuple (`Types`), so schema-bound consumers (the + * engine port) can type their per-type behaviour off it. A plain * `: SearchSchema` annotation widens gracefully to `SearchType`. + * + * `values()` yields only Root Types – Reference Types are held apart in a name + * index ({@link referenceTypeNamed}), so a Writer that opens one collection per + * `values()` entry can never open one for a Reference Type. */ /** Brand for {@link SearchSchema}: type-only, no runtime existence. Makes the * schema NOMINAL – a hand-built `Map` is not assignable, so `searchSchema()` @@ -282,19 +326,44 @@ export declare const validSearchSchema: unique symbol; export interface SearchSchema< Types extends readonly SearchType[] = readonly SearchType[], -> extends ReadonlyMap { +> extends ReadonlyMap> { readonly [validSearchSchema]: true; } /** - * Build a {@link SearchSchema} from root-type declarations, keyed by `class`. + * The Reference Type name index each {@link SearchSchema} carries alongside its + * class-keyed root map, kept out of the map itself so no consumer iterating + * `values()` ever meets a Reference Type. Held in a `WeakMap` rather than a + * property, so the schema stays a plain branded `Map` and the index is read only + * through {@link referenceTypeNamed}. + */ +const referenceTypesBySchema = new WeakMap< + SearchSchema, + ReadonlyMap +>(); + +/** Whether a declared type is a {@link RootType} (declares a `class`) rather + * than a {@link ReferenceType}. */ +function isRootType(searchType: SearchType): searchType is RootType { + return searchType.class !== undefined; +} + +/** + * Build a {@link SearchSchema} from type declarations. Its arguments are + * **partitioned**: {@link RootType}s (those declaring a `class`) key the map – + * so a Writer opens exactly one collection per Root Type – and + * {@link ReferenceType}s (no `class`) go into a name index that an inline + * `ref.typeName` resolves against ({@link referenceTypeNamed}). * * Every declaration is validated ({@link assertValidSearchType}) – the * declaration-time counterpart of the port’s `assertValidQuery` – and the - * schema-wide invariants are enforced: no two types may share a `class` IRI - * (they would silently overwrite each other in the map) or a `name` (names - * key the API surfaces). Throws on the first invalid declaration, so a bad - * schema fails at startup, not per document at index time or per query. + * schema-wide invariants are enforced: no two Root Types may share a `class` IRI + * (they would silently overwrite each other in the map) and no two types may + * share a `name` (names key the API surfaces, across Root and Reference Types + * alike). Every inline reference must resolve to a declared Reference Type, and + * the inline reference graph must be acyclic – the only way its depth could be + * unbounded. Throws on the first invalid declaration, so a bad schema fails at + * startup, not per document at index time or per query. */ export function searchSchema( ...types: Types @@ -303,24 +372,147 @@ export function searchSchema( const names = new Set(); for (const searchType of types) { assertValidSearchType(searchType); - if (typeIris.has(searchType.class)) { - throw new Error( - `Duplicate search type IRI “${searchType.class}”; each SearchType must declare a distinct class.`, - ); + if (isRootType(searchType)) { + if (typeIris.has(searchType.class)) { + throw new Error( + `Duplicate search type IRI “${searchType.class}”; each Root Type must declare a distinct class.`, + ); + } + typeIris.add(searchType.class); } if (names.has(searchType.name)) { throw new Error( `Duplicate search type name “${searchType.name}”; each SearchType must declare a distinct name.`, ); } - typeIris.add(searchType.class); names.add(searchType.name); } + const referenceTypes = new Map( + types + .filter((searchType) => !isRootType(searchType)) + .map((searchType) => [searchType.name, searchType]), + ); + assertResolvableInlineReferences(types, referenceTypes); assertResolvableLabelSources(types); // The one blessed cast: only this validated constructor mints the brand. - return new Map( - types.map((searchType) => [searchType.class, searchType]), + const schema = new Map( + types + .filter(isRootType) + .map((searchType) => [searchType.class, searchType]), ) as unknown as SearchSchema; + referenceTypesBySchema.set(schema, referenceTypes); + return schema; +} + +/** + * The {@link ReferenceType} an inline `ref.typeName` names, or `undefined` when + * the schema declares none by that name. The read side of the name index + * {@link searchSchema} partitions the Reference Types into – the projection + * resolves an inline reference’s referent shape through it. + */ +export function referenceTypeNamed( + schema: SearchSchema, + name: string, +): ReferenceType | undefined { + return referenceTypesBySchema.get(schema)?.get(name); +} + +/** Whether a field is an inline reference – a {@link ReferenceField} whose + * `ref` carries its referent’s projected fields ({@link ReferenceType}). */ +export function isInlineReference( + field: SearchField, +): field is ReferenceField & { + readonly ref: { readonly typeName: string; readonly strategy: 'inline' }; +} { + return field.kind === 'reference' && field.ref?.strategy === 'inline'; +} + +/** + * The framing depth a Root Type needs: how many hops the inline reference graph + * reaches from it (`Dataset → Subset → Measurement` is two), floored at one so + * the existing single-hop embed for non-inline references is preserved. Depth is + * a property of the declaration, bounded because {@link searchSchema} rejects + * inline cycles – never a knob or a constant. Framing bounded per batch keeps + * memory bounded by the unit of work (ADR 12), not the graph. + */ +export function inlineFramingDepth( + schema: SearchSchema, + searchType: SearchType, +): number { + return Math.max(1, inlineChainLength(schema, searchType)); +} + +/** The longest inline reference chain reachable from `searchType`. The recursion + * terminates because {@link searchSchema} rejects inline cycles; a reference the + * given schema does not declare (e.g. a type framed against another schema) + * contributes no depth. */ +function inlineChainLength( + schema: SearchSchema, + searchType: SearchType, +): number { + let longest = 0; + for (const field of searchType.fields) { + if (!isInlineReference(field)) { + continue; + } + const referent = referenceTypeNamed(schema, field.ref.typeName); + if (referent === undefined) { + continue; + } + longest = Math.max(longest, 1 + inlineChainLength(schema, referent)); + } + return longest; +} + +/** + * Every inline `ref.typeName` must resolve to a **declared Reference Type**, and + * the inline reference graph must be acyclic. Unlike a `labelOnly` reference – + * whose `typeName` is just an API name – an inline reference carries its + * referent’s fields, so it must know their shape, and an inline cycle is the one + * way framing depth could be unbounded. Checked schema-wide, because a single + * declaration cannot see its siblings. + */ +function assertResolvableInlineReferences( + types: readonly SearchType[], + referenceTypes: ReadonlyMap, +): void { + for (const searchType of types) { + for (const field of searchType.fields) { + if (!isInlineReference(field)) { + continue; + } + if (!referenceTypes.has(field.ref.typeName)) { + throw new Error( + `Inline reference “${searchType.name}.${field.name}” names “${field.ref.typeName}”, which is not a declared reference type; declare a reference type (a SearchType with no class) with that name.`, + ); + } + } + } + for (const referenceType of referenceTypes.values()) { + assertNoInlineCycle(referenceType, referenceTypes, new Set()); + } +} + +function assertNoInlineCycle( + referenceType: ReferenceType, + referenceTypes: ReadonlyMap, + onPath: ReadonlySet, +): void { + if (onPath.has(referenceType.name)) { + throw new Error( + `Inline reference cycle through reference type “${referenceType.name}”; an inline reference graph must be acyclic, so its framing depth stays bounded.`, + ); + } + const extended = new Set([...onPath, referenceType.name]); + for (const field of referenceType.fields) { + if (!isInlineReference(field)) { + continue; + } + // Resolvability is validated before any cycle check, so every inline + // `typeName` here names a declared reference type. + const referent = referenceTypes.get(field.ref.typeName) as ReferenceType; + assertNoInlineCycle(referent, referenceTypes, extended); + } } /** @@ -554,7 +746,7 @@ interface FlatField extends SearchFieldBase, Searchable, RangeFacetable { */ export function assertTypeInSchema( schema: SearchSchema, - searchType: SearchType, + searchType: RootType, ): void { if (schema.get(searchType.class) !== searchType) { throw new Error( diff --git a/packages/search/src/testing.ts b/packages/search/src/testing.ts index fd6ac84c..c46c9015 100644 --- a/packages/search/src/testing.ts +++ b/packages/search/src/testing.ts @@ -6,11 +6,7 @@ import { type FilterOperator, type SearchQuery, } from './query.js'; -import { - facetableFields, - filterableFields, - type SearchType, -} from './schema.js'; +import { facetableFields, filterableFields, type RootType } from './schema.js'; /** * The executable {@link SearchEngine} port contract (import from @@ -33,8 +29,8 @@ export function describeSearchEngineContract( engine: () => SearchEngine, ): void { describe(`SearchEngine port contract: ${name}`, () => { - const types = (): readonly SearchType[] => [...engine().schema.values()]; - const browse = (searchType: SearchType): SearchQuery => ({ + const types = (): readonly RootType[] => [...engine().schema.values()]; + const browse = (searchType: RootType): SearchQuery => ({ where: [], orderBy: [], limit: 1, @@ -52,7 +48,7 @@ export function describeSearchEngineContract( }); it('rejects a search type outside its schema', async () => { - const foreign: SearchType = { + const foreign: RootType = { name: 'NotInSchema', class: 'urn:test:not-in-schema', fields: [], @@ -104,7 +100,7 @@ export function describeSearchEngineContract( }); it('rejects a searchFacets batch for a type outside its schema', async () => { - const foreign: SearchType = { + const foreign: RootType = { name: 'NotInSchema', class: 'urn:test:not-in-schema', fields: [], @@ -194,7 +190,7 @@ function mismatchedFilter( } /** The locale a query against this type may select (any is contract-valid). */ -function firstLocale(searchType: SearchType): string { +function firstLocale(searchType: RootType): string { for (const field of searchType.fields) { if (field.kind === 'text' && field.locales.length > 0) { return field.locales[0]; diff --git a/packages/search/test/frame-by-type.test.ts b/packages/search/test/frame-by-type.test.ts index f38d8eca..1b8639a6 100644 --- a/packages/search/test/frame-by-type.test.ts +++ b/packages/search/test/frame-by-type.test.ts @@ -154,6 +154,61 @@ describe('frameSubjects', () => { expect(framed.map((node) => node['@id'])).toEqual(['https://ex/d/1']); }); + it('embeds only one hop by default, but follows the reference graph to a declared depth', async () => { + // Two hops of forward references – the Dataset Register’s IIIF shape: + // dataset → void:subset → dqv:hasQualityMeasurement → dqv:value. + const subset = 'http://rdfs.org/ns/void#subset'; + const measurement = 'http://www.w3.org/ns/dqv#hasQualityMeasurement'; + const value = 'http://www.w3.org/ns/dqv#value'; + const ntriples = ` + <${subset}> . + <${measurement}> . + <${value}> "42" . + `; + + // Depth 1: the subset is embedded, but its measurement is not resolved past + // its @id (the grandchild’s value is absent). + const [shallow] = await collect( + frameSubjects(buildSubjectIndex(quads(ntriples)), ['https://ex/d/1'], 1), + ); + expect(shallow[subset]).toMatchObject({ '@id': 'https://ex/s/1' }); + expect( + (shallow[subset] as Record)[measurement], + ).toMatchObject({ '@id': 'https://ex/m/1' }); + expect( + ( + (shallow[subset] as Record)[measurement] as Record< + string, + unknown + > + )[value], + ).toBeUndefined(); + + // Depth 2: the grandchild measurement’s value is embedded too. + const [deep] = await collect( + frameSubjects(buildSubjectIndex(quads(ntriples)), ['https://ex/d/1'], 2), + ); + const deepMeasurement = (deep[subset] as Record)[ + measurement + ] as Record; + expect(deepMeasurement[value]).toBe('42'); + }); + + it('terminates on a reference cycle in the data, visiting each subject once', async () => { + // a → b → a is a cycle; a diamond (a → b, a → c, b → d, c → d) revisits d. + // Both would loop or duplicate without the visited set. + const link = dcterms.source.value; + const index = buildSubjectIndex( + quads(` + <${link}> . + <${link}> . + `), + ); + const [node] = await collect(frameSubjects(index, ['https://ex/a'], 5)); + expect(node['@id']).toBe('https://ex/a'); + expect(node[link]).toMatchObject({ '@id': 'https://ex/b' }); + }); + it('frames nothing for a root absent from the index', async () => { expect( await collect( diff --git a/packages/search/test/project.test.ts b/packages/search/test/project.test.ts index 6c74e613..7a06c4cd 100644 --- a/packages/search/test/project.test.ts +++ b/packages/search/test/project.test.ts @@ -483,6 +483,225 @@ describe('projectDocument', () => { expect(document).not.toHaveProperty('classes'); }); + it('flattens an inline reading-device reference with a derive, then prunes it', () => { + // The reading device (ADR 11): an inline reference declaring no role is an + // internal field. Its referent’s fields are projected so a derive can select + // and flatten a value a path cannot address; the internal field is pruned + // before the writer sees it. + const registration = defineSearchType({ + name: 'Registration', + fields: [ + { name: 'dateRead', kind: 'date', path: 'https://schema.org/dateRead' }, + { + name: 'datePosted', + kind: 'date', + path: 'https://schema.org/datePosted', + }, + ], + }); + const dataset = defineSearchType({ + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'registration', + kind: 'reference', + array: true, + path: `${DR}registration`, + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + { + name: 'datePosted', + kind: 'date', + output: true, + // Select the newest registration by dateRead, flatten its datePosted. + derive: (document) => { + const registrations = + (document.registration as + | readonly { dateRead?: number; datePosted?: number }[] + | undefined) ?? []; + return [...registrations].sort( + (left, right) => (right.dateRead ?? 0) - (left.dateRead ?? 0), + )[0]?.datePosted; + }, + }, + ], + }); + const withReference = searchSchema(dataset, registration); + + const document = projectDocument( + { + '@id': 'https://ex/d/reg', + [`${DR}registration`]: [ + { + '@id': 'https://ex/r/1', + 'https://schema.org/dateRead': { '@value': '2024-01-01T00:00:00Z' }, + 'https://schema.org/datePosted': { + '@value': '2024-02-01T00:00:00Z', + }, + }, + { + '@id': 'https://ex/r/2', + 'https://schema.org/dateRead': { '@value': '2024-06-01T00:00:00Z' }, + 'https://schema.org/datePosted': { + '@value': '2024-07-01T00:00:00Z', + }, + }, + ], + }, + dataset, + withReference, + ); + + // The derive read the newest registration (r/2, read 2024-06) and flattened + // its datePosted (2024-07)… + expect(document.datePosted).toBe( + Math.trunc(Date.parse('2024-07-01T00:00:00Z') / 1000), + ); + // …and the internal inline reference itself never reaches the writer. + expect(document).not.toHaveProperty('registration'); + }); + + it('leaves an inline reference absent when the node carries no referent', () => { + const registration = defineSearchType({ + name: 'Registration', + fields: [ + { name: 'dateRead', kind: 'date', path: 'https://schema.org/dateRead' }, + ], + }); + const dataset = defineSearchType({ + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'registration', + kind: 'reference', + array: true, + output: true, + path: `${DR}registration`, + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + }); + const withReference = searchSchema(dataset, registration); + const document = projectDocument( + { '@id': 'https://ex/d/empty' }, + dataset, + withReference, + ); + expect(document).toEqual({ id: 'https://ex/d/empty' }); + }); + + it('projects nothing for an inline reference when no schema is supplied', () => { + // An inline reference is a nested structure that can only be resolved with a + // schema; projected without one, it must not fall through to a bare-IRI + // facet under its own name. + const dataset: SearchType = { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'registration', + kind: 'reference', + output: true, + path: `${DR}registration`, + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + }; + const document = projectDocument( + { + '@id': 'https://ex/d/noschema', + [`${DR}registration`]: { '@id': 'https://ex/r/1' }, + }, + dataset, + ); + expect(document).toEqual({ id: 'https://ex/d/noschema' }); + }); + + it('skips an inline reference the given schema does not declare', () => { + // projectDocument does not check type membership (projectRoots does); framed + // against a schema that omits the referent, an inline reference contributes + // no nesting rather than throwing. + const dataset = defineSearchType({ + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'registration', + kind: 'reference', + output: true, + path: `${DR}registration`, + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + }); + const foreignSchema = searchSchema({ + name: 'Other', + class: 'urn:other', + fields: [], + }); + const document = projectDocument( + { + '@id': 'https://ex/d/foreign', + [`${DR}registration`]: { '@id': 'https://ex/r/9' }, + }, + dataset, + foreignSchema, + ); + expect(document).not.toHaveProperty('registration'); + }); + + it('surfaces an inline output reference as a nested document (API device)', () => { + const creator = defineSearchType({ + name: 'Creator', + fields: [ + { + name: 'label', + kind: 'text', + path: 'https://schema.org/name', + locales: ['nl'], + output: true, + searchable: { weight: 1 }, + }, + ], + }); + const dataset = defineSearchType({ + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'creator', + kind: 'reference', + output: true, + path: `${DR}creator`, + ref: { typeName: 'Creator', strategy: 'inline' }, + }, + ], + }); + const withReference = searchSchema(dataset, creator); + + const document = projectDocument( + { + '@id': 'https://ex/d/api', + [`${DR}creator`]: { + '@id': 'https://ex/c/1', + 'https://schema.org/name': { '@language': 'nl', '@value': 'Naam' }, + }, + }, + dataset, + withReference, + ); + + // An output inline reference surfaces its referent as a nested Search + // Document (its Reference Type’s projected fields), not a bare IRI. + expect(document.creator).toMatchObject({ + id: 'https://ex/c/1', + label_nl: 'Naam', + label_search_nl: 'naam', + }); + }); + it('buckets untagged literals into the reserved und locale', () => { const document = projectDocument( { diff --git a/packages/search/test/schema.test.ts b/packages/search/test/schema.test.ts index 973a218f..996a9181 100644 --- a/packages/search/test/schema.test.ts +++ b/packages/search/test/schema.test.ts @@ -8,11 +8,13 @@ import { facetableFields, fieldNamed, filterableFields, + inlineFramingDepth, isoToUnixSeconds, isRangeFacet, outputFields, physicalFields, referenceFields, + referenceTypeNamed, searchableFields, searchSchema, sortableFields, @@ -498,6 +500,162 @@ describe('searchSchema validation', () => { ).toThrow(/Duplicate search type name/); }); + describe('reference types and inline references', () => { + const registration = { + name: 'Registration', + fields: [ + { name: 'dateRead', kind: 'date', path: 'https://schema.org/dateRead' }, + { + name: 'datePosted', + kind: 'date', + path: 'https://schema.org/datePosted', + }, + ], + } as const; + + const datasetWithInline = { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'registration', + kind: 'reference', + array: true, + path: 'urn:lde:Dataset/registration', + ref: { typeName: 'Registration', strategy: 'inline' }, + }, + ], + } as const; + + it('accepts a reference type (no class) and keeps it out of the indexed map', () => { + const schema = searchSchema(datasetWithInline, registration); + // The class-keyed map – and so every writer’s collections – holds only + // the Root Type. The Reference Type is reachable only through the + // reference. + expect([...schema.values()].map((type) => type.name)).toEqual([ + 'Dataset', + ]); + expect(referenceTypeNamed(schema, 'Registration')).toEqual(registration); + expect(referenceTypeNamed(schema, 'Dataset')).toBeUndefined(); + }); + + it('resolves an inline ref.typeName against the declared reference types', () => { + expect(() => searchSchema(datasetWithInline, registration)).not.toThrow(); + }); + + it('rejects an inline reference whose typeName names no declared type', () => { + expect(() => searchSchema(datasetWithInline)).toThrow( + /inline reference .*Registration.*declare a reference type/i, + ); + }); + + it('rejects an inline reference resolving to a root type, not a reference type', () => { + expect(() => + searchSchema(datasetWithInline, { + name: 'Registration', + class: 'urn:other', + fields: [], + }), + ).toThrow(/inline reference .*Registration.*reference type/i); + }); + + it('rejects an inline cycle', () => { + const a = { + name: 'A', + fields: [ + { + name: 'toB', + kind: 'reference', + path: 'urn:lde:A/toB', + ref: { typeName: 'B', strategy: 'inline' }, + }, + ], + } as const; + const b = { + name: 'B', + fields: [ + { + name: 'toA', + kind: 'reference', + path: 'urn:lde:B/toA', + ref: { typeName: 'A', strategy: 'inline' }, + }, + ], + } as const; + expect(() => + searchSchema( + { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'a', + kind: 'reference', + path: 'urn:lde:Dataset/a', + ref: { typeName: 'A', strategy: 'inline' }, + }, + ], + }, + a, + b, + ), + ).toThrow(/inline (reference )?cycle/i); + }); + + it('computes the framing depth from the inline reference chain', () => { + const measurement = { + name: 'Measurement', + fields: [ + { + name: 'value', + kind: 'number', + path: 'http://www.w3.org/ns/dqv#value', + }, + ], + } as const; + const subset = { + name: 'Subset', + fields: [ + { + name: 'measurement', + kind: 'reference', + array: true, + path: 'http://www.w3.org/ns/dqv#hasQualityMeasurement', + ref: { typeName: 'Measurement', strategy: 'inline' }, + }, + ], + } as const; + const dataset = { + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'subset', + kind: 'reference', + array: true, + path: 'http://rdfs.org/ns/void#subset', + ref: { typeName: 'Subset', strategy: 'inline' }, + }, + ], + } as const; + const chainSchema = searchSchema(dataset, subset, measurement); + // Dataset → Subset → Measurement is two hops. + expect(inlineFramingDepth(chainSchema, dataset)).toBe(2); + // A root type with no inline reference frames its own one hop. + const flatDataset = { + name: 'Flat', + class: 'urn:flat', + fields: [], + } as const; + expect(inlineFramingDepth(searchSchema(flatDataset), flatDataset)).toBe( + 1, + ); + // Against a schema that does not declare the referent, an inline reference + // contributes no depth (the type is being framed through a foreign schema). + expect(inlineFramingDepth(searchSchema(flatDataset), dataset)).toBe(1); + }); + }); + describe('reference label sources', () => { const organization = { name: 'Organization', diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index 59157cac..f4bc7c25 100644 --- a/packages/search/vite.config.ts +++ b/packages/search/vite.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( thresholds: { functions: 100, lines: 100, - branches: 98.2, + branches: 98.81, statements: 100, }, }, From 9d248792cd1b70ad854f4e89fa618d600e98748a Mon Sep 17 00:00:00 2001 From: David de Boer Date: Mon, 20 Jul 2026 20:24:43 +0200 Subject: [PATCH 2/2] feat(search): prune internal fields recursively through surfaced inline referents - Prune internal (no-role) fields at every depth: a surfaced (output) inline reference now has its referent's internal helper fields removed before the writer, so the "a field without a role reaches neither the engine nor the API" invariant holds inside nested documents, not just at the root. - Pruning runs as one post-order pass after all projection, so a derive at any depth still reads a helper field before it is removed. - Mark ADR 11 Accepted. --- ...decouple-rdf-depth-from-the-api-surface.md | 2 +- packages/search/src/project.ts | 71 +++++++++++++----- packages/search/test/project.test.ts | 75 +++++++++++++++++++ packages/search/vite.config.ts | 2 +- 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md b/docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md index 456b6590..bcb35b09 100644 --- a/docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md +++ b/docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md @@ -4,7 +4,7 @@ Date: 2026-07-16 ## Status -Proposed +Accepted Extends the reference model of [ADR 8 (Resolve reference labels from per-reference label sources)](./0008-resolve-reference-labels-from-per-reference-label-sources.md) diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index 7981dcc7..e5f46320 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -34,9 +34,11 @@ export type SearchDocument = { id: string } & Record; * statement of what the projection reads. {@link isInternalField Internal * fields} (those declaring no role) are populated so a later derive can read * them, then pruned before the document is returned: they must reach neither a - * writer nor the collection definition. The physical field names a field fans - * out to come from {@link physicalFields}, the single source shared with the - * engine collection definition and the query compiler. + * writer nor the collection definition. Pruning ({@link pruneInternalFields}) + * recurses into the referents of surfaced inline references, so that invariant + * holds at every depth, not just the root. The physical field names a field + * fans out to come from {@link physicalFields}, the single source shared with + * the engine collection definition and the query compiler. */ export function projectDocument( node: FramedNode, @@ -44,22 +46,55 @@ export function projectDocument( schema?: SearchSchema, ): SearchDocument { const document = projectFields(node, searchType, schema); - // Prune internal fields: they were projected only so the derives above could - // read them (a structural memo shared across every derive that needs the - // value), and must not reach the writer or the collection definition. + pruneInternalFields(document, searchType, schema); + return document; +} + +/** + * Prune every internal field from a fully projected document, in place. Runs + * only after all projection – so every derive that might read an internal + * field, at any depth, has already run – which makes this single post-order + * pass safe. A no-role inline reference is itself internal, so it is deleted + * whole here; a surfaced (`output`) inline reference survives, but its own + * internal helper fields are pruned from the nested document. That keeps the + * *a field without a role reaches neither the engine nor the API* invariant + * true at every depth of the reference graph, not just at the root. + */ +function pruneInternalFields( + document: SearchDocument, + searchType: SearchType, + schema: SearchSchema | undefined, +): void { for (const field of searchType.fields) { if (isInternalField(field)) { delete document[field.name]; + continue; + } + // A surfaced inline reference nests its referent(s) as SearchDocument(s); + // prune those too, by their reference type. + if (schema === undefined || field.kind !== 'reference') { + continue; + } + const ref = field.ref; + if (ref?.strategy !== 'inline') { + continue; + } + const referenceType = referenceTypeNamed(schema, ref.typeName); + const nested = document[field.name]; + if (referenceType === undefined || nested === undefined) { + continue; + } + for (const referent of Array.isArray(nested) ? nested : [nested]) { + pruneInternalFields(referent as SearchDocument, referenceType, schema); } } - return document; } /** Apply every field of `searchType` to a fresh document, without pruning – the - * shared core of {@link projectDocument} (which then prunes internal fields) - * and inline-referent projection (which keeps them, because the referent’s - * fields exist to be read by the declaring type’s derives or surfaced whole, - * and whether it surfaces is decided by the *declaring* field’s roles). */ + * shared core of {@link projectDocument} and inline-referent projection. + * Pruning is deferred to a single recursive pass ({@link pruneInternalFields}) + * once the whole nested structure is projected, so a derive at any depth can + * still read an internal field before it is removed. */ function projectFields( node: FramedNode, searchType: SearchType, @@ -258,13 +293,13 @@ function applyFacet( * each projected through the reference’s {@link ReferenceType} (its fields’ * paths relative to the referent node) and attached under the field’s name – a * nested {@link SearchDocument} for a single reference, an array for an - * `array` one. The nested fields keep their internal fields un-pruned: they - * exist to be read by the declaring type’s derives (the *reading device*) or - * surfaced whole (the *API device*), and whether the nesting reaches the writer - * is decided by *this* field’s roles – an internal inline reference is pruned in - * full by {@link projectDocument}. Recurses through `schema`, so an inline - * reference may itself carry further inline references to the schema’s declared - * depth. The referent type is guaranteed declared by {@link searchSchema}. + * `array` one. The referent is projected in full – internal fields included – + * so the declaring type’s (or the reference type’s own) derives can read them; + * {@link pruneInternalFields} then removes the internal fields from a *surfaced* + * referent and deletes an internal inline reference whole. Recurses through + * `schema`, so an inline reference may itself carry further inline references to + * the schema’s declared depth. The referent type is guaranteed declared by + * {@link searchSchema}. */ function applyInlineReference( document: SearchDocument, diff --git a/packages/search/test/project.test.ts b/packages/search/test/project.test.ts index 7a06c4cd..918fc4c3 100644 --- a/packages/search/test/project.test.ts +++ b/packages/search/test/project.test.ts @@ -702,6 +702,81 @@ describe('projectDocument', () => { }); }); + it('prunes an internal helper field from a surfaced (output) inline referent, after a derive reads it', () => { + // A Reference Type may carry an internal helper field – no role – that its + // own derive reads. When the reference is surfaced (`output`), the invariant + // *a field without a role reaches neither the engine nor the API* must still + // hold inside the nested document: the helper is projected (so the derive + // reads it) then pruned, while the derived output field survives. + const creator = defineSearchType({ + name: 'Creator', + fields: [ + { + name: 'label', + kind: 'text', + path: 'https://schema.org/name', + locales: ['nl'], + output: true, + searchable: { weight: 1 }, + }, + // Internal helper: no role, read by the derive below, pruned from the + // surfaced referent. + { + name: 'rawSort', + kind: 'keyword', + path: 'https://schema.org/alternateName', + }, + { + name: 'sortLabel', + kind: 'keyword', + output: true, + derive: (referent) => + (referent.rawSort as readonly string[] | undefined)?.[0], + }, + ], + }); + const dataset = defineSearchType({ + name: 'Dataset', + class: DATASET, + fields: [ + { + name: 'creator', + kind: 'reference', + array: true, + output: true, + path: `${DR}creator`, + ref: { typeName: 'Creator', strategy: 'inline' }, + }, + ], + }); + const withReference = searchSchema(dataset, creator); + + const document = projectDocument( + { + '@id': 'https://ex/d/prune', + [`${DR}creator`]: [ + { + '@id': 'https://ex/c/2', + 'https://schema.org/name': { '@language': 'nl', '@value': 'Naam' }, + 'https://schema.org/alternateName': 'Alt', + }, + ], + }, + dataset, + withReference, + ); + + const [referent] = document.creator as SearchDocument[]; + // The derive read the helper (sortLabel carries its value)… + expect(referent).toMatchObject({ + id: 'https://ex/c/2', + label_nl: 'Naam', + sortLabel: 'Alt', + }); + // …but the internal helper itself never surfaces in the nested document. + expect(referent).not.toHaveProperty('rawSort'); + }); + it('buckets untagged literals into the reserved und locale', () => { const document = projectDocument( { diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index f4bc7c25..667a216e 100644 --- a/packages/search/vite.config.ts +++ b/packages/search/vite.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( thresholds: { functions: 100, lines: 100, - branches: 98.81, + branches: 98.87, statements: 100, }, },