Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 9 additions & 8 deletions packages/search-api-graphql/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
type Filter,
type LocalizedValue,
type RootType,
type SearchEngine,
type SearchField,
type SearchQuery,
Expand Down Expand Up @@ -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<Record<string, SearchTypeOptions>>;
/** Output-language ordering; defaults to Accept-Language-first, `und` last. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<Source, SearchContext> {
const typeName = searchType.name;
Expand Down Expand Up @@ -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).
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/search-api-graphql/src/facet-batch.ts
Original file line number Diff line number Diff line change
@@ -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}. */
Expand All @@ -26,7 +26,7 @@ export type FacetLoader = (field: string) => Promise<readonly FacetBucket[]>;
*/
export function createFacetLoader(
engine: SearchEngine,
searchType: SearchType,
searchType: RootType,
query: SearchQuery,
onFacetError?: (field: string, error: unknown) => void,
): FacetLoader {
Expand Down
9 changes: 5 additions & 4 deletions packages/search-api-graphql/test/build-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -658,7 +659,7 @@ describe('buildGraphQLSchema', () => {
const searchedTypes: string[] = [];
const engine: SearchEngine = {
schema: searchSchema(PERSON, CREATIVE_WORK),
async search(searchType: SearchType): Promise<SearchResult> {
async search(searchType: RootType): Promise<SearchResult> {
searchedTypes.push(searchType.class);
return { total: 0, hits: [], facets: {} };
},
Expand Down Expand Up @@ -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' },
},
],
Expand All @@ -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]])));
});

Expand Down
15 changes: 8 additions & 7 deletions packages/search-pipeline/src/search-index-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SearchDocument>;
writerFor: (searchType: RootType) => Writer<SearchDocument>;
}

/**
Expand Down Expand Up @@ -113,7 +114,7 @@ export function searchIndexWriter(
string,
{ queue: AsyncQueue<SearchDocument>; done: Promise<void> }
>();
const laneFor = (searchType: SearchType) => {
const laneFor = (searchType: RootType) => {
let lane = lanes.get(searchType.class);
if (lane === undefined) {
const run = runs.get(searchType.class);
Expand Down
13 changes: 7 additions & 6 deletions packages/search-pipeline/src/search-stages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <class> }`. 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
Expand All @@ -136,7 +137,7 @@ export function searchStages(
* SPARQL reader).
*/
export function selectByClass(
searchType: SearchType,
searchType: RootType,
rootVariable = 'root',
): ItemSelector {
return new SparqlItemSelector({
Expand Down
6 changes: 3 additions & 3 deletions packages/search-pipeline/src/typed-search-document.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string> = {
[DATASET]: 'datasets',
Expand Down Expand Up @@ -81,7 +81,7 @@ function mixedQuads(): Quad[] {
async function paired(
quads: readonly Quad[],
roots: readonly string[],
searchType: SearchType,
searchType: RootType,
): Promise<TypedSearchDocument[]> {
const documents: TypedSearchDocument[] = [];
for await (const document of projectRoots(quads, roots, schema, searchType)) {
Expand Down Expand Up @@ -113,7 +113,7 @@ function makeRunContext(): RunContext {

function blueGreenFor(
client: Client,
): (searchType: SearchType) => Writer<SearchDocument> {
): (searchType: RootType) => Writer<SearchDocument> {
return (searchType) =>
new BlueGreenRebuild(client, searchType, {
name: COLLECTION[searchType.class],
Expand Down Expand Up @@ -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<SearchDocument> = (
const failing: (searchType: RootType) => Writer<SearchDocument> = (
searchType,
) => {
const real = blueGreenFor(client)(searchType);
Expand Down
50 changes: 46 additions & 4 deletions packages/search-pipeline/test/search-index-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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[];
Expand All @@ -91,7 +92,7 @@ function makeFleet(
const collections = new Map<string, FakeCollection>();
const commitOverride = overrides.commit;

const writerFor = (searchType: SearchType): Writer<SearchDocument> => {
const writerFor = (searchType: RootType): Writer<SearchDocument> => {
const collection: FakeCollection = {
searchType,
writes: [],
Expand Down Expand Up @@ -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<SearchDocument> => {
built.push(searchType.name);
return {
openRun: () => Promise.resolve({} as RunWriter<SearchDocument>),
};
};
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);
Expand Down Expand Up @@ -413,7 +455,7 @@ describe('searchIndexWriter', () => {
const opened: SearchType[] = [];
const aborted: SearchType[] = [];
const failure = new Error('lock held');
const writerFor = (searchType: SearchType): Writer<SearchDocument> => ({
const writerFor = (searchType: RootType): Writer<SearchDocument> => ({
openRun: async () => {
// The second type to open fails; the first must be rolled back.
if (opened.length === 1) {
Expand Down
Loading