From 7c2a561dcc98c40f34e2dcbc62a9a2fdef81ba67 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Sat, 18 Jul 2026 20:22:00 +0200 Subject: [PATCH] feat(search)!: make path the whole statement of what projection reads - Drop the FramedNode argument from `derive`: a derive now computes only from the document projected so far, never the graph, so a field's `path` is the complete statement of what the projection reads. - Honour the no-role-means-internal promise for every kind via a shared `isInternalField` predicate: an internal field is projected so a later derive can read it, then pruned before the writer, and omitted from the Typesense collection definition (not stored, not indexed, no RAM). - Unexport the IR readers (`irisOf`, `literalsOf`, `firstLiteralOf`); they are now internal to projection. - Update the field-model docs and tests for the new behaviour. --- .../test/multi-collection.integration.test.ts | 14 ++- .../test/search-stages.test.ts | 4 +- .../src/collection-definition.ts | 17 ++- .../test/blue-green-rebuild.test.ts | 4 +- .../test/collection-definition.test.ts | 53 +++++++++ .../test/in-place-rebuild.test.ts | 2 +- packages/search-typesense/vite.config.ts | 2 +- packages/search/README.md | 40 +++---- packages/search/src/adapter.ts | 1 + packages/search/src/index.ts | 6 +- packages/search/src/project.ts | 36 ++++--- packages/search/src/schema.ts | 47 ++++++-- packages/search/test/project.test.ts | 101 ++++++++++++++++-- packages/search/vite.config.ts | 2 +- 14 files changed, 266 insertions(+), 63 deletions(-) diff --git a/packages/search-pipeline/test/multi-collection.integration.test.ts b/packages/search-pipeline/test/multi-collection.integration.test.ts index 05c7a2d9..601d9310 100644 --- a/packages/search-pipeline/test/multi-collection.integration.test.ts +++ b/packages/search-pipeline/test/multi-collection.integration.test.ts @@ -31,12 +31,22 @@ const schema = searchSchema( { name: 'Dataset', class: DATASET, - fields: [{ name: 'title', kind: 'keyword', path: TITLE, array: true }], + fields: [ + { + name: 'title', + kind: 'keyword', + path: TITLE, + array: true, + output: true, + }, + ], }, { name: 'Organization', class: ORGANIZATION, - fields: [{ name: 'name', kind: 'keyword', path: NAME, array: true }], + fields: [ + { name: 'name', kind: 'keyword', path: NAME, array: true, output: true }, + ], }, ); diff --git a/packages/search-pipeline/test/search-stages.test.ts b/packages/search-pipeline/test/search-stages.test.ts index 377d6f98..f6049880 100644 --- a/packages/search-pipeline/test/search-stages.test.ts +++ b/packages/search-pipeline/test/search-stages.test.ts @@ -22,7 +22,7 @@ const NAME = 'https://example.org/name'; const schema = searchSchema({ name: 'Person', class: PERSON, - fields: [{ name: 'name', kind: 'keyword', path: NAME }], + fields: [{ name: 'name', kind: 'keyword', path: NAME, output: true }], }); const person = schema.get(PERSON) as SearchType; @@ -115,7 +115,7 @@ describe('searchStages', () => { const lookalike: SearchType = { name: 'Person', class: PERSON, - fields: [{ name: 'name', kind: 'keyword', path: NAME }], + fields: [{ name: 'name', kind: 'keyword', path: NAME, output: true }], }; const [stage] = searchStages({ schema, diff --git a/packages/search-typesense/src/collection-definition.ts b/packages/search-typesense/src/collection-definition.ts index 3a077c02..5554d8b5 100644 --- a/packages/search-typesense/src/collection-definition.ts +++ b/packages/search-typesense/src/collection-definition.ts @@ -1,7 +1,11 @@ import type { CollectionCreateSchema } from 'typesense'; import type { CollectionFieldSchema } from 'typesense/lib/Typesense/Collection.js'; import { type SearchField, type SearchType } from '@lde/search'; -import { displayFieldPattern, physicalFields } from '@lde/search/adapter'; +import { + displayFieldPattern, + isInternalField, + physicalFields, +} from '@lde/search/adapter'; import { deriveCollectionName } from './collection-name.js'; /** Deployment-specific options the generic field model does not carry. */ @@ -46,6 +50,10 @@ export interface CollectionDefinitionOptions { * only the folded `*_search_${locale}`, facet/reference and `*_sort_${locale}` * companions are indexed. Keeping retrieval-only fields un-indexed is the lever * for holding a large index’s RAM down. + * + * {@link isInternalField Internal fields} (those declaring no role) are omitted + * entirely: they exist only as a projection-time reading device for the + * derives, so the collection stores nothing for them. */ export function buildCollectionDefinition( searchType: SearchType, @@ -73,6 +81,13 @@ function typesenseFields( defaultLocale: string | undefined, defaultSortingField: string | undefined, ): CollectionFieldSchema[] { + // An internal field (no role) is projected as a reading device for the + // derives and pruned before the writer, so the collection stores nothing for + // it: not stored, not indexed, no RAM. Same predicate the projection prunes + // by, so the index and the document cannot disagree. + if (isInternalField(field)) { + return []; + } const names = physicalFields(field); if (field.kind === 'text') { const locales = field.locales; diff --git a/packages/search-typesense/test/blue-green-rebuild.test.ts b/packages/search-typesense/test/blue-green-rebuild.test.ts index a1492c7e..31dd72a0 100644 --- a/packages/search-typesense/test/blue-green-rebuild.test.ts +++ b/packages/search-typesense/test/blue-green-rebuild.test.ts @@ -13,8 +13,8 @@ const datasetType: SearchType = { name: 'Dataset', class: 'https://example.org/Dataset', fields: [ - { name: 'title', kind: 'keyword' }, - { name: 'year', kind: 'integer' }, + { name: 'title', kind: 'keyword', output: true }, + { name: 'year', kind: 'integer', facetable: true }, ], }; diff --git a/packages/search-typesense/test/collection-definition.test.ts b/packages/search-typesense/test/collection-definition.test.ts index 5a16f70b..e3c89c29 100644 --- a/packages/search-typesense/test/collection-definition.test.ts +++ b/packages/search-typesense/test/collection-definition.test.ts @@ -266,3 +266,56 @@ describe('und-locale text', () => { ]); }); }); + +describe('internal fields', () => { + it('omits an internal (zero-role) field of every kind from the collection', () => { + const collection = buildCollectionDefinition( + { + name: 'Doc', + class: 'urn:example:Doc', + fields: [ + { name: 'token', path: 'urn:ex:token', kind: 'keyword' }, + { name: 'ref', path: 'urn:ex:ref', kind: 'reference' }, + { name: 'count', path: 'urn:ex:count', kind: 'integer' }, + { name: 'score', path: 'urn:ex:score', kind: 'number' }, + { name: 'flag', path: 'urn:ex:flag', kind: 'boolean' }, + { name: 'note', path: 'urn:ex:note', kind: 'text', locales: ['nl'] }, + ], + }, + { name: 'docs' }, + ); + // Every field declares no role, so it is internal – a projection-time + // reading device, pruned before the writer. The collection stores nothing + // for any of them: not stored, not indexed, no RAM. + expect(collection.fields).toEqual([]); + }); + + it('keeps a field the moment it declares any role', () => { + const collection = buildCollectionDefinition( + { + name: 'Doc', + class: 'urn:example:Doc', + fields: [ + { name: 'hidden', path: 'urn:ex:hidden', kind: 'keyword' }, + { + name: 'shown', + path: 'urn:ex:shown', + kind: 'keyword', + facetable: true, + }, + ], + }, + { name: 'docs' }, + ); + // Only the field carrying a role reaches the collection. + expect(collection.fields).toEqual([ + { + name: 'shown', + type: 'string', + facet: true, + sort: false, + optional: true, + }, + ]); + }); +}); diff --git a/packages/search-typesense/test/in-place-rebuild.test.ts b/packages/search-typesense/test/in-place-rebuild.test.ts index 00d26736..9f352a8f 100644 --- a/packages/search-typesense/test/in-place-rebuild.test.ts +++ b/packages/search-typesense/test/in-place-rebuild.test.ts @@ -12,7 +12,7 @@ const NAME = 'objects'; const objectType: SearchType = { name: 'Object', class: 'https://example.org/Object', - fields: [{ name: 'title', kind: 'keyword' }], + fields: [{ name: 'title', kind: 'keyword', output: true }], }; const datasetA = new Dataset({ diff --git a/packages/search-typesense/vite.config.ts b/packages/search-typesense/vite.config.ts index 6b54c7ba..5a9ae6d5 100644 --- a/packages/search-typesense/vite.config.ts +++ b/packages/search-typesense/vite.config.ts @@ -20,7 +20,7 @@ export default mergeConfig( // partial vitest run must never rewrite these – see AGENTS.md. functions: 98.65, lines: 98.83, - branches: 93.57, + branches: 93.61, statements: 98.85, }, }, diff --git a/packages/search/README.md b/packages/search/README.md index 0f9208eb..a7a0a84d 100644 --- a/packages/search/README.md +++ b/packages/search/README.md @@ -58,8 +58,7 @@ a typed, deterministic function – easy to test, and swappable per deployment. Exports are stratified by audience: - **`@lde/search`** – the authoring surface: `defineSearchType`, - `searchSchema`, `projectRoots` (+ the IR readers `derive` functions use), - validation, and every model/query/result type. + `searchSchema`, `projectRoots`, validation, and every model/query/result type. - **`@lde/search/adapter`** – plumbing for engine adapters and API surfaces: `physicalFields`, the field selectors, `assertValidQuery`, the filter operators and storage codecs. @@ -101,20 +100,19 @@ Two conventions hold across the whole family: ## Field model The mapping is data, not code. Each field declares its `kind`, the IR `path` to -read (or a `derive` function for a **derived** field, computed from the framed -node in declaration order – so it may read fields declared before it), and the -capabilities it opts into. The physical field names a declaration fans out to -(per-locale search/sort keys) come from -`physicalFields`, the single convention projection, the collection definition and the -query compiler all share. +read (or a `derive` function for a **derived** field, computed from the document +in declaration order – so it may read fields declared before it, never the +graph), and the capabilities (**roles**) it opts into. `path` is therefore the +complete statement of what the projection reads from the graph. A field that +declares **no** role is an **internal field**: projected so a later `derive` can +read it, then pruned before the writer and absent from the collection definition +– not stored, not indexed, no RAM. The physical field names a declaration fans +out to (per-locale search/sort keys) come from `physicalFields`, the single +convention projection, the collection definition and the query compiler all +share. ```ts -import { - defineSearchType, - projectRoots, - irisOf, - searchSchema, -} from '@lde/search'; +import { defineSearchType, projectRoots, searchSchema } from '@lde/search'; const DATASET = defineSearchType({ name: 'Dataset', // logical API name: names the GraphQL type, a REST path, … @@ -141,12 +139,17 @@ const DATASET = defineSearchType({ }, // → size (int) { name: 'size', path: 'urn:dr:size', kind: 'integer', sortable: true }, - // derived field (no path): computed from the framed node + // internal field (no role): projected as a reading device for the derive + // below, then pruned before the writer – absent from the collection too + { name: 'classes', path: 'urn:dr:class', kind: 'reference' }, + // derived field (no path): computed from the document in declaration order, + // never from the graph – so `path` stays the whole statement of what is read { name: 'classCount', kind: 'integer', sortable: true, - derive: (node) => irisOf(node, 'urn:dr:class').length, + derive: (document) => + (document.classes as string[] | undefined)?.length ?? 0, }, ], }); @@ -225,8 +228,9 @@ each root’s subgraph is framed one at a time and its document yielded as produced, so beyond a subject index memory stays flat at scale (framing the whole graph at once is roughly O(N²)). Duplicate triples are collapsed first, because some SPARQL engines (e.g. QLever) do not deduplicate `CONSTRUCT` output. -The IR carries no `@context`, so a `derive` function reading it sees full -predicate IRIs with language tags preserved. `assertTypeInSchema` guards that +Every predicate a value comes from is read through a field’s `path`; a `derive` +computes only from the document projected so far, so `path` is the whole +statement of what the projection reads. `assertTypeInSchema` guards that the passed `SearchType` is a member of the schema – the port’s own membership check – so no schema is ever forged to scope a projection to one type. diff --git a/packages/search/src/adapter.ts b/packages/search/src/adapter.ts index df467bbd..2f277062 100644 --- a/packages/search/src/adapter.ts +++ b/packages/search/src/adapter.ts @@ -17,6 +17,7 @@ export { filterableFields, sortableFields, outputFields, + isInternalField, referenceFields, fieldNamed, labelFieldOf, diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts index 09eb36ba..787e503f 100644 --- a/packages/search/src/index.ts +++ b/packages/search/src/index.ts @@ -5,9 +5,9 @@ // `@lde/search/testing`. // Projection: RDF CONSTRUCT quads → flat search documents, driven by the -// unified SearchField/SearchType model. The IR readers (irisOf, …) are here -// because `derive` functions are written against them. -export { projectRoots, irisOf, literalsOf, firstLiteralOf } from './project.js'; +// unified SearchField/SearchType model. A `derive` reads the projected document, +// never the graph, so the IR readers stay internal to projection. +export { projectRoots } from './project.js'; export type { SearchDocument } from './project.js'; // Unified field model: one declaration drives projection, engine collection diff --git a/packages/search/src/project.ts b/packages/search/src/project.ts index b9883ad5..719a9531 100644 --- a/packages/search/src/project.ts +++ b/packages/search/src/project.ts @@ -8,6 +8,7 @@ import { import { assertTypeInSchema, displayFieldName, + isInternalField, isoToUnixSeconds, physicalFields, type KeywordField, @@ -24,10 +25,14 @@ export type SearchDocument = { id: string } & Record; /** * Project one framed JSON-LD node into a flat search document: apply each field * of the type in declaration order. A field with a `derive` function computes - * its value from the node and the document as populated so far (so a derived - * field may read fields declared before it). 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. + * its value from the document as populated so far (so a derived field may read + * fields declared before it), never from the graph – `path` is the complete + * 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. */ export function projectDocument( node: FramedNode, @@ -43,6 +48,14 @@ export function projectDocument( for (const field of searchType.fields) { applyField(document, node, field); } + // 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. + for (const field of searchType.fields) { + if (isInternalField(field)) { + delete document[field.name]; + } + } return document; } @@ -82,7 +95,7 @@ function applyField( field: SearchField, ): void { if (field.derive !== undefined) { - const value = field.derive(node, document); + const value = field.derive(document); if (value !== undefined) { document[field.name] = value; } @@ -209,7 +222,9 @@ function applyFacet( } } -// --- Framed-IR readers (exported so derivations can read arbitrary paths) --- +// --- 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. /** A literal value with its (possibly empty) language tag. */ interface LangValue { @@ -223,20 +238,17 @@ function langValuesOf(node: FramedNode, path: string): LangValue[] { .filter((value): value is LangValue => value !== undefined); } -export function literalsOf(node: FramedNode, path: string): string[] { +function literalsOf(node: FramedNode, path: string): string[] { return valuesOf(node, path) .map(literalString) .filter((value): value is string => value !== undefined); } -export function firstLiteralOf( - node: FramedNode, - path: string, -): string | undefined { +function firstLiteralOf(node: FramedNode, path: string): string | undefined { return literalsOf(node, path)[0]; } -export function irisOf(node: FramedNode, path: string): string[] { +function irisOf(node: FramedNode, path: string): string[] { return valuesOf(node, path) .map(iriString) .filter((value): value is string => value !== undefined); diff --git a/packages/search/src/schema.ts b/packages/search/src/schema.ts index a9032b9b..09298f0a 100644 --- a/packages/search/src/schema.ts +++ b/packages/search/src/schema.ts @@ -1,4 +1,3 @@ -import type { FramedNode } from './frame-by-type.js'; import type { SearchDocument } from './project.js'; /** @@ -57,9 +56,11 @@ export function filterOperatorFor(kind: FieldKind): FilterOperator | undefined { * Capability flags (`searchable`/`filterable`/`facetable`/`sortable`/`output`) * are independent opt-ins: a field exposes exactly the roles it declares. A * field with a {@link SearchFieldBase.derive `derive`} function instead of a - * `path` is a **derived field** – computed from the framed node rather than - * projected from the IR – yet it still carries full query/schema/output - * behavior (e.g. `status`, the compatibility booleans). + * `path` is a **derived field** – computed from the document projected so far + * rather than read from the graph – yet it still carries full query/schema/output + * behavior (e.g. `status`, the compatibility booleans). A field declaring **no** + * role at all is an {@link isInternalField **internal field**}: projected so a + * later derive can read it, then pruned before the writer. * * The physical field names a declaration fans out to (per-locale search/sort * keys) follow one convention, owned by @@ -104,15 +105,17 @@ export interface SearchFieldBase { readonly sortable?: boolean; /** * Compute this field’s value instead of projecting it from a `path` – a - * status token, a compatibility boolean, a count over the framed node. + * status token, a compatibility boolean, a count over an earlier field. * Mutually exclusive with `path`. Runs in declaration order during - * projection, receiving the framed node and the document as populated so - * far, so a derived field may read fields declared before it (e.g. a - * `statusRank` reading the derived `status`). Return `undefined` to leave - * the field absent. The field still carries full query/schema/output - * behaviour like any other. + * projection, receiving **only** the document as populated so far – never the + * graph – so a derived field reads fields declared before it (e.g. a + * `statusRank` reading the derived `status`, or a count reading an + * {@link isInternalField internal} field). Return `undefined` to leave the + * field absent. The field still carries full query/schema/output behaviour + * like any other. Reading only the document is what keeps `path` the complete + * statement of what the projection reads from the graph. */ - readonly derive?: (node: FramedNode, document: SearchDocument) => unknown; + readonly derive?: (document: SearchDocument) => unknown; } /** Full-text inclusion with a `query_by` weight (folded; per-locale for @@ -687,6 +690,28 @@ export function outputFields(searchType: SearchType): readonly SearchField[] { return searchType.fields.filter((field) => field.output === true); } +/** + * Whether a field declares **no** role – none of `output`, `searchable`, + * `filterable`, `facetable`, `sortable`. Such a field is an **internal field**: + * the projection populates it (so a later {@link SearchFieldBase.derive} can + * read it), then prunes it before the document reaches a writer, and the engine + * collection definition omits it entirely – not stored, not indexed, no RAM. + * Absence of a role declares that intent; there is no separate marker flag. + * + * The single predicate the projection and the collection definition share, so + * they cannot disagree on what is internal. See the Search context’s load-bearing + * line: *a field without a Role is an Internal Field.* + */ +export function isInternalField(field: SearchField): boolean { + return ( + field.output !== true && + field.searchable === undefined && + field.filterable !== true && + field.facetable !== true && + field.sortable !== true + ); +} + /** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */ export function referenceFields( searchType: SearchType, diff --git a/packages/search/test/project.test.ts b/packages/search/test/project.test.ts index f0a09ef0..6c74e613 100644 --- a/packages/search/test/project.test.ts +++ b/packages/search/test/project.test.ts @@ -4,7 +4,6 @@ import { dcat, dcterms, xsd } from '@tpluscode/rdf-ns-builders'; import { projectDocument, projectRoots, - irisOf, type SearchDocument, } from '../src/project.js'; import { @@ -55,6 +54,7 @@ const fields: SearchField[] = [ name: 'publisher', path: dcterms.publisher.value, kind: 'reference', + facetable: true, }, { name: 'keyword', @@ -66,15 +66,17 @@ const fields: SearchField[] = [ name: 'format', path: `${DR}format`, kind: 'keyword', + facetable: true, transform: (value) => value.replace(IANA, ''), }, - { name: 'class', path: `${DR}class`, kind: 'reference' }, + { name: 'class', path: `${DR}class`, kind: 'reference', facetable: true }, { name: 'date_posted', path: `${DR}datePosted`, kind: 'date', + sortable: true, }, - { name: 'size', path: `${DR}size`, kind: 'integer' }, + { name: 'size', path: `${DR}size`, kind: 'integer', facetable: true }, ]; const schema: SearchType = { @@ -85,7 +87,11 @@ const schema: SearchType = { { name: 'class_count', kind: 'integer', - derive: (framed) => irisOf(framed, `${DR}class`).length, + sortable: true, + // Reads the `class` reference already projected into the document – + // never the graph – so `path` stays the whole statement of what is read. + derive: (document) => + (document.class as readonly string[] | undefined)?.length ?? 0, }, ], }; @@ -129,11 +135,17 @@ describe('projectDocument', () => { name: 'Dataset', class: DATASET, fields: [ - { name: 'size', path: `${DR}size`, kind: 'integer' }, + { + name: 'size', + path: `${DR}size`, + kind: 'integer', + facetable: true, + }, { name: 'language', path: dcterms.language.value, kind: 'keyword', + facetable: true, }, { name: 'keyword', @@ -145,6 +157,7 @@ describe('projectDocument', () => { name: 'class', path: `${DR}class`, kind: 'reference', + facetable: true, }, ], }, @@ -161,7 +174,9 @@ describe('projectDocument', () => { { name: 'Dataset', class: DATASET, - fields: [{ name: 'size', path: `${DR}size`, kind: 'number' }], + fields: [ + { name: 'size', path: `${DR}size`, kind: 'number', facetable: true }, + ], }, ); expect(document.size).toBe(1234.5); @@ -171,7 +186,9 @@ describe('projectDocument', () => { const withBoolean: SearchType = { name: 'Dataset', class: DATASET, - fields: [{ name: 'iiif', path: `${DR}iiif`, kind: 'boolean' }], + fields: [ + { name: 'iiif', path: `${DR}iiif`, kind: 'boolean', facetable: true }, + ], }; const project = (value: unknown): SearchDocument => projectDocument( @@ -389,7 +406,7 @@ describe('projectDocument', () => { name: 'statusRank', kind: 'integer', sortable: true, - derive: (_node, partial) => (partial.status === 'valid' ? 1 : 0), + derive: (document) => (document.status === 'valid' ? 1 : 0), }, // Returning undefined leaves the field absent. { name: 'absent', kind: 'keyword', derive: () => undefined }, @@ -406,6 +423,66 @@ describe('projectDocument', () => { expect(document).not.toHaveProperty('external'); }); + it('prunes an internal (zero-role) field of every non-text kind from the document', () => { + const document = projectDocument( + { + '@id': 'https://ex/d/internal', + [`${DR}token`]: ['tok'], + [`${DR}ref`]: { '@id': 'https://ex/o/9' }, + [`${DR}count`]: { '@value': '7' }, + [`${DR}score`]: { '@value': '1.5' }, + [`${DR}flag`]: { '@value': 'true' }, + }, + { + name: 'Dataset', + class: DATASET, + fields: [ + { name: 'token', path: `${DR}token`, kind: 'keyword' }, + { name: 'ref', path: `${DR}ref`, kind: 'reference' }, + { name: 'count', path: `${DR}count`, kind: 'integer' }, + { name: 'score', path: `${DR}score`, kind: 'number' }, + { name: 'flag', path: `${DR}flag`, kind: 'boolean' }, + ], + }, + ); + // Each field declares no role, so it is internal: projected then pruned. + // The document a writer sees carries only its id – it reaches neither a + // writer nor the collection definition. + expect(document).toEqual({ id: 'https://ex/d/internal' }); + }); + + it('projects an internal field so a later derive reads it, then prunes the internal field', () => { + const document = projectDocument( + { + '@id': 'https://ex/d/reading-device', + [`${DR}class`]: [ + { '@id': 'http://schema.org/Person' }, + { '@id': 'http://schema.org/Place' }, + ], + }, + { + name: 'Dataset', + class: DATASET, + fields: [ + // An internal reading device: a reference with no role, projected so + // the derive below can read it, pruned before the writer sees it. + { name: 'classes', path: `${DR}class`, kind: 'reference' }, + { + name: 'classCount', + kind: 'integer', + facetable: true, + derive: (document) => + (document.classes as readonly string[] | undefined)?.length ?? 0, + }, + ], + }, + ); + // The derive read the internal field’s value… + expect(document.classCount).toBe(2); + // …but the internal field itself never reaches the writer. + expect(document).not.toHaveProperty('classes'); + }); + it('buckets untagged literals into the reserved und locale', () => { const document = projectDocument( { @@ -480,11 +557,17 @@ describe('projectDocument', () => { locales: ['nl'], output: true, }, - { name: 'keyword', path: dcat.keyword.value, kind: 'keyword' }, + { + name: 'keyword', + path: dcat.keyword.value, + kind: 'keyword', + facetable: true, + }, { name: 'publisher', path: dcterms.publisher.value, kind: 'reference', + facetable: true, }, ], }, diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index e59cd028..59157cac 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.14, + branches: 98.2, statements: 100, }, },