@lde/search 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -393
- package/dist/adapter.d.ts +2 -2
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/query.d.ts +23 -5
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +37 -4
- package/dist/schema.d.ts +22 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +20 -1
- package/dist/testing.d.ts +3 -2
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +20 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,400 +6,12 @@ a running search engine needs is derived from it: the document projection, the
|
|
|
6
6
|
engine collection definition, the query semantics, and the API surface. All these
|
|
7
7
|
are kept in sync automatically rather than handwritten per deployment.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
engine, no API protocol, and no domain vocabulary. The engine- and API-specific
|
|
11
|
-
halves are adapters that plug into the ports defined here:
|
|
9
|
+
## Installation
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **API surfaces** drive it, parsing client input into `search(SearchQuery)`:
|
|
16
|
-
[`@lde/search-api-graphql`](../search-api-graphql), with a REST
|
|
17
|
-
surface to follow.
|
|
18
|
-
|
|
19
|
-
The library never names your domain: the same core drives a `Dataset`,
|
|
20
|
-
`Person`, or `CreativeWork` search.
|
|
21
|
-
|
|
22
|
-
It provides four things:
|
|
23
|
-
|
|
24
|
-
- **unified field model** – `SearchField` / `SearchType` / `SearchSchema`:
|
|
25
|
-
one declaration per field that drives all four consumers below, so they
|
|
26
|
-
cannot drift;
|
|
27
|
-
- **neutral query IR** – `SearchQuery` / `Filter` / `Sort` + filter
|
|
28
|
-
semantics: every API surface compiles into it, every engine adapter compiles
|
|
29
|
-
out of it, so the two cannot drift;
|
|
30
|
-
- **engine port** – `SearchEngine` and the logical result types
|
|
31
|
-
(`SearchResult` / `SearchHit` / `ResultDocument` / `Reference` / …);
|
|
32
|
-
- **streaming projection** – `projectRoots`, RDF `CONSTRUCT` quads → flat
|
|
33
|
-
search documents, one root type at a time.
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
SearchSchema ─┬─► projection (projectRoots → flat documents) [here]
|
|
37
|
-
├─► engine adapter (collection definition + query compiler) e.g. @lde/search-typesense
|
|
38
|
-
├─► query semantics (SearchQuery, filter/sort/facet) [here]
|
|
39
|
-
└─► API surface (GraphQL / REST) e.g. @lde/search-api-graphql
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
At runtime, everything those consumers do is a **pure transformation between
|
|
43
|
-
data shapes**, each one parameterised by the schema – three chains, meeting at
|
|
44
|
-
the engine:
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
indexing: RDF quads ──frame──► FramedNode ──project──► SearchDocument ──import──► engine
|
|
48
|
-
querying: client input ──parse──► SearchQuery ──compile──► engine query
|
|
49
|
-
results: engine response ──parse──► SearchResult ──shape──► API output
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Validation happens before the first arrow (SHACL over the RDF) and inside the
|
|
53
|
-
last (the engine enforces its collection definition); between them every stage is
|
|
54
|
-
a typed, deterministic function – easy to test, and swappable per deployment.
|
|
55
|
-
|
|
56
|
-
## Entry points
|
|
57
|
-
|
|
58
|
-
Exports are stratified by audience:
|
|
59
|
-
|
|
60
|
-
- **`@lde/search`** – the authoring surface: `defineSearchType`,
|
|
61
|
-
`searchSchema`, `projectRoots`, validation, and every model/query/result type.
|
|
62
|
-
- **`@lde/search/adapter`** – plumbing for engine adapters and API surfaces:
|
|
63
|
-
`physicalFields`, the field selectors, `assertValidQuery`, the filter
|
|
64
|
-
operators and storage codecs.
|
|
65
|
-
- **`@lde/search/module`** – `loadSchemaModule`, the Node-only loader for a
|
|
66
|
-
mounted schema-declaration module (an `.mjs` default-exporting declarations
|
|
67
|
-
as plain data). The one loader both the indexer image and the served-API
|
|
68
|
-
image boot from, so the write and the read side cannot disagree about the
|
|
69
|
-
schema.
|
|
70
|
-
- **`@lde/search/testing`** – `describeSearchEngineContract`, the executable
|
|
71
|
-
port contract every engine adapter runs against a live instance of itself
|
|
72
|
-
(vitest; optional peer).
|
|
73
|
-
|
|
74
|
-
## Terminology
|
|
75
|
-
|
|
76
|
-
The model has three levels, with analogues in SHACL ([one possible source](#why-a-declarative-model))
|
|
77
|
-
and GraphQL (one of the surfaces):
|
|
78
|
-
|
|
79
|
-
| Term | What it is | SHACL | GraphQL |
|
|
80
|
-
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ----------- |
|
|
81
|
-
| `SearchField` | One queryable field: a `kind`, the IR `path` it projects from, and the capability flags it opts into | property shape | field |
|
|
82
|
-
| `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 |
|
|
83
|
-
| `SearchSchema` | The whole search declaration: every Root Type, keyed by `class` IRI, plus the Reference Types – build one with `searchSchema(...types)` | shapes graph | schema |
|
|
84
|
-
|
|
85
|
-
A `SearchType` is a **Root Type** or a **Reference Type**, told apart by one
|
|
86
|
-
absence: a Root Type declares a `class`, so roots are selected for it and a
|
|
87
|
-
writer opens a collection for it; a Reference Type declares none, so it is
|
|
88
|
-
never selected, framed by type or indexed – its identity is its name, and its
|
|
89
|
-
type comes from the edge that points at it. `searchSchema` partitions its
|
|
90
|
-
arguments accordingly: Root Types key the class map (`schema.values()` yields
|
|
91
|
-
only them, so no writer ever opens a collection for a Reference Type), Reference
|
|
92
|
-
Types go into a name index an inline `ref.typeName` resolves against. The
|
|
93
|
-
absence is load-bearing, and enforced at the type level – an indexed Reference
|
|
94
|
-
Type fails to compile ([ADR 11](../../docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md)).
|
|
95
|
-
|
|
96
|
-
`projectRoots` and the engine port each execute one `SearchType` at a time –
|
|
97
|
-
projection over the roots the pipeline selector supplied for that type; the
|
|
98
|
-
GraphQL surface consumes the whole `SearchSchema`.
|
|
99
|
-
|
|
100
|
-
### API conventions
|
|
101
|
-
|
|
102
|
-
Two conventions hold across the whole family:
|
|
103
|
-
|
|
104
|
-
- **Parameter order** – a function takes the value it operates on first and
|
|
105
|
-
the declaration right after it: `projectDocument(node, type)`,
|
|
106
|
-
`buildSearchParams(query, type)`,
|
|
107
|
-
`createTypesenseSearchEngine(client, schema, options)`,
|
|
108
|
-
`engine.search(type, query)`.
|
|
109
|
-
- **Factory verbs** – the verb tells you what kind of thing comes back.
|
|
110
|
-
`define*` captures a declaration as a literal (`defineSearchType`);
|
|
111
|
-
`build*` is a pure data-to-data constructor (`buildCollectionDefinition`,
|
|
112
|
-
`buildSearchParams`, `buildGraphQLSchema`); `create*` makes a stateful
|
|
113
|
-
instance (`createTypesenseSearchEngine`). A bare noun (`searchSchema`)
|
|
114
|
-
constructs the trivial container it names.
|
|
115
|
-
|
|
116
|
-
## Field model
|
|
117
|
-
|
|
118
|
-
The mapping is data, not code. Each field declares its `kind`, the IR `path` to
|
|
119
|
-
read (or a `derive` function for a **derived** field, computed from the document
|
|
120
|
-
in declaration order – so it may read fields declared before it, never the
|
|
121
|
-
graph), and the capabilities (**roles**) it opts into. `path` is therefore the
|
|
122
|
-
complete statement of what the projection reads from the graph. A field that
|
|
123
|
-
declares **no** role is an **internal field**: projected so a later `derive` can
|
|
124
|
-
read it, then pruned before the writer and absent from the collection definition
|
|
125
|
-
– not stored, not indexed, no RAM. The physical field names a declaration fans
|
|
126
|
-
out to (per-locale search/sort keys) come from `physicalFields`, the single
|
|
127
|
-
convention projection, the collection definition and the query compiler all
|
|
128
|
-
share.
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
import { defineSearchType, projectRoots, searchSchema } from '@lde/search';
|
|
132
|
-
|
|
133
|
-
const DATASET = defineSearchType({
|
|
134
|
-
name: 'Dataset', // logical API name: names the GraphQL type, a REST path, …
|
|
135
|
-
class: 'http://www.w3.org/ns/dcat#Dataset',
|
|
136
|
-
fields: [
|
|
137
|
-
// → title_<lang> (display, every present language), title_search_nl/_en, title_sort_nl/_en
|
|
138
|
-
{
|
|
139
|
-
name: 'title',
|
|
140
|
-
path: 'http://purl.org/dc/terms/title',
|
|
141
|
-
kind: 'text',
|
|
142
|
-
locales: ['nl', 'en'],
|
|
143
|
-
output: true,
|
|
144
|
-
searchable: { weight: 5 },
|
|
145
|
-
sortable: true,
|
|
146
|
-
},
|
|
147
|
-
// → publisher (IRI facet, resolved to a labelled reference at the surface)
|
|
148
|
-
{
|
|
149
|
-
name: 'publisher',
|
|
150
|
-
path: 'http://purl.org/dc/terms/publisher',
|
|
151
|
-
kind: 'reference',
|
|
152
|
-
facetable: true,
|
|
153
|
-
output: true,
|
|
154
|
-
ref: { typeName: 'Organization', strategy: 'labelOnly' },
|
|
155
|
-
},
|
|
156
|
-
// → size (int)
|
|
157
|
-
{ name: 'size', path: 'urn:dr:size', kind: 'integer', sortable: true },
|
|
158
|
-
// internal field (no role): projected as a reading device for the derive
|
|
159
|
-
// below, then pruned before the writer – absent from the collection too
|
|
160
|
-
{ name: 'classes', path: 'urn:dr:class', kind: 'reference' },
|
|
161
|
-
// derived field (no path): computed from the document in declaration order,
|
|
162
|
-
// never from the graph – so `path` stays the whole statement of what is read
|
|
163
|
-
{
|
|
164
|
-
name: 'classCount',
|
|
165
|
-
kind: 'integer',
|
|
166
|
-
sortable: true,
|
|
167
|
-
derive: (document) =>
|
|
168
|
-
(document.classes as string[] | undefined)?.length ?? 0,
|
|
169
|
-
},
|
|
170
|
-
],
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const schema = searchSchema(DATASET);
|
|
174
|
-
for await (const document of projectRoots(quads, roots, schema, DATASET)) {
|
|
175
|
-
// one flat search document per given root subject, streamed. The caller (the
|
|
176
|
-
// pipeline selector) supplies `roots`; pairing a document with its type for a
|
|
177
|
-
// multi-collection writer is the pipeline glue’s job (see
|
|
178
|
-
// `@lde/search-pipeline`), not the projection’s.
|
|
179
|
-
}
|
|
11
|
+
```sh
|
|
12
|
+
npm install @lde/search
|
|
180
13
|
```
|
|
181
14
|
|
|
182
|
-
|
|
183
|
-
`as const satisfies SearchType` would do manually, with nothing to remember),
|
|
184
|
-
so typed facet/output keys can be derived from it – see
|
|
185
|
-
[Typed results](#typed-results) and `@lde/search-api-graphql`.
|
|
186
|
-
|
|
187
|
-
**Kinds** (`FieldKind`): `text`, `keyword`, `integer`, `number`, `boolean`,
|
|
188
|
-
`date`, `reference`. The Typesense/engine vocabulary and the GraphQL types are
|
|
189
|
-
_derived_ from the kind by the adapter and the surface – never declared here.
|
|
190
|
-
|
|
191
|
-
`SearchField` is a **discriminated union by `kind`** (`TextField`,
|
|
192
|
-
`KeywordField`, `ReferenceField`, `NumericField`, `BooleanField`): each kind
|
|
193
|
-
declares exactly the properties it can honour – `locales` on text, `ref` on
|
|
194
|
-
references, `facetRanges` on numerics – so an illegal declaration fails to
|
|
195
|
-
compile. Text is **always multilingual in shape**: `locales` lists the
|
|
196
|
-
language tags to **index** (search/sort), and the reserved **`und`** locale
|
|
197
|
-
(JSON-LD `@none`, RDF `und`) buckets untagged literals – a monolingual or
|
|
198
|
-
untagged corpus declares `locales: ['und']`, mixed data `['nl', 'und']`.
|
|
199
|
-
Display, by contrast, keeps every language present, not only the listed ones
|
|
200
|
-
(see [Locales](#locales)). Declaring a real language is recommended (it drives
|
|
201
|
-
per-locale stemming); `und` is folded but unstemmed unless `defaultLocale` opts
|
|
202
|
-
in, is never demoted in search weighting, and adding a language later is
|
|
203
|
-
additive – the API output shape never changes. Use `keyword` for exact-match
|
|
204
|
-
tokens, never for prose.
|
|
205
|
-
|
|
206
|
-
**Declarations are also validated at runtime** (for declarations built
|
|
207
|
-
outside TypeScript – a SHACL generator, plain JS): `searchSchema()`
|
|
208
|
-
rejects a structurally invalid declaration (duplicate field names, an `output`
|
|
209
|
-
reference without `ref`, `text` without locales, `locales` on a non-text kind,
|
|
210
|
-
`facetRanges` on a non-numeric kind, `searchable`/`transform` on a kind whose
|
|
211
|
-
projection cannot honour it, `filterable`/`facetable` on `text`, two types
|
|
212
|
-
sharing a `class` IRI or `name`) – the declaration-time counterpart of the
|
|
213
|
-
port’s always-on query validation, so a bad schema fails at startup rather
|
|
214
|
-
than per document at index time. `validateSearchType` /
|
|
215
|
-
`assertValidSearchType` are exported for validating a single declaration
|
|
216
|
-
directly.
|
|
217
|
-
|
|
218
|
-
| kind | `where` | facet | sort | output |
|
|
219
|
-
| -------------------- | -------------------- | ----- | ---------------- | ------------------------------- |
|
|
220
|
-
| `text` | – (feeds free text) | – | yes (per-locale) | best-first language list |
|
|
221
|
-
| `keyword` | `in` (membership) | yes | – | string / `string[]` |
|
|
222
|
-
| `reference` | `in` (membership) | yes | – | labelled reference (id + label) |
|
|
223
|
-
| `integer` / `number` | `range { min, max }` | yes | yes | number |
|
|
224
|
-
| `date` | `range` (inclusive) | yes | yes | ISO 8601 string (surface) |
|
|
225
|
-
| `boolean` | `is` | yes | – | boolean (absent = false) |
|
|
226
|
-
|
|
227
|
-
A `reference` carries one of two strategies today: `labelOnly` (id + display
|
|
228
|
-
label, resolved at query time from a label source) and `inline` (the referent’s
|
|
229
|
-
own projected fields, carried inline). `idOnly` stays a forward declaration.
|
|
230
|
-
|
|
231
|
-
An **inline reference** resolves `ref.typeName` to a declared **Reference Type**
|
|
232
|
-
and projects the referent through it – a nested `SearchDocument`, or an array
|
|
233
|
-
for an `array` reference. Roles decide whether the nesting surfaces, so the same
|
|
234
|
-
construct serves two jobs (see
|
|
235
|
-
[ADR 11](../../docs/decisions/0011-decouple-rdf-depth-from-the-api-surface.md)):
|
|
236
|
-
|
|
237
|
-
- a **reading device** declares no role, so it is an internal field: projected
|
|
238
|
-
so a later `derive` can select and flatten a value a `path` cannot address (a
|
|
239
|
-
qualified hop), then pruned before the writer – nothing nested reaches the
|
|
240
|
-
engine or the API;
|
|
241
|
-
- an **API device** declares `output`, deliberately surfacing the nested
|
|
242
|
-
Reference Type.
|
|
243
|
-
|
|
244
|
-
So RDF depth and API shape stay independent: inline as deep as the source
|
|
245
|
-
demands, expose exactly the flat fields you want. Framing follows the inline
|
|
246
|
-
reference graph to the depth the schema declares (`Dataset → Subset →
|
|
247
|
-
Measurement` is two hops), and `searchSchema` rejects inline cycles – the one
|
|
248
|
-
way that depth could be unbounded – so it stays a bounded property of the
|
|
249
|
-
declaration.
|
|
250
|
-
|
|
251
|
-
A reference resolves its label from a **label source**: `labelSource` names
|
|
252
|
-
the `SearchType` whose collection holds the referenced entities. The named
|
|
253
|
-
type must declare an `output`, `searchable` text field called `label` –
|
|
254
|
-
`searchSchema` validates this schema-wide, so a dangling or unsuitable label
|
|
255
|
-
source fails at startup. A reference without a `labelSource` stays id-only.
|
|
256
|
-
|
|
257
|
-
## Projection
|
|
258
|
-
|
|
259
|
-
`projectRoots` projects **one root type** over the roots the caller supplies –
|
|
260
|
-
the pipeline selector already holds them, so nothing is discovered from
|
|
261
|
-
`rdf:type` and a `CONSTRUCT` need emit no type triple. It is fully streaming:
|
|
262
|
-
each root’s subgraph is framed one at a time and its document yielded as
|
|
263
|
-
produced, so beyond a subject index memory stays flat at scale (framing the
|
|
264
|
-
whole graph at once is roughly O(N²)). Duplicate triples are collapsed first,
|
|
265
|
-
because some SPARQL engines (e.g. QLever) do not deduplicate `CONSTRUCT` output.
|
|
266
|
-
Every predicate a value comes from is read through a field’s `path`; a `derive`
|
|
267
|
-
computes only from the document projected so far, so `path` is the whole
|
|
268
|
-
statement of what the projection reads. `assertTypeInSchema` guards that
|
|
269
|
-
the passed `SearchType` is a member of the schema – the port’s own membership
|
|
270
|
-
check – so no schema is ever forged to scope a projection to one type.
|
|
271
|
-
|
|
272
|
-
**Blank-node roots are not indexable**: a blank node has no stable document
|
|
273
|
-
key, so framing skips a blank-node root (and any root absent from the index)
|
|
274
|
-
rather than crash. Blank-node subjects still embed fine when _referenced_ from
|
|
275
|
-
a root; they just cannot _be_ one – select roots accordingly
|
|
276
|
-
(`selectByClass` in `@lde/search-pipeline` already excludes them).
|
|
277
|
-
|
|
278
|
-
`projectRoots` yields a **bare** `SearchDocument`. Pairing each document with the
|
|
279
|
-
`SearchType` it belongs to, so the write side can fan a mixed stream out to
|
|
280
|
-
per-type collections, is a routing concern owned by the pipeline glue – see
|
|
281
|
-
`@lde/search-pipeline`’s `searchStages` and multi-collection writer – not the
|
|
282
|
-
projection. One stage per root type keeps `@lde/search` pipeline-free.
|
|
283
|
-
|
|
284
|
-
`projectRoots` **consumes the quads once** – a single scan builds the subject
|
|
285
|
-
index the roots frame off – and so accepts any `Iterable<Quad>`, not just a
|
|
286
|
-
materialized array. A caller merging several readers can pass a chained
|
|
287
|
-
generator instead of building a third full array at the projection peak:
|
|
288
|
-
|
|
289
|
-
```ts
|
|
290
|
-
projectRoots(
|
|
291
|
-
(function* () {
|
|
292
|
-
yield* registerQuads;
|
|
293
|
-
yield* dkgQuads;
|
|
294
|
-
})(),
|
|
295
|
-
roots,
|
|
296
|
-
schema,
|
|
297
|
-
DATASET,
|
|
298
|
-
);
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
## Locales
|
|
302
|
-
|
|
303
|
-
`locales` declares the languages a `text` field wants **indexed** (`und` =
|
|
304
|
-
untagged literals). It drives the two per-locale, in-memory fanouts –
|
|
305
|
-
`searchable` and `sortable` – but **not** display, which preserves every
|
|
306
|
-
language present:
|
|
307
|
-
|
|
308
|
-
- `output` → `title_<lang>` for **every** language the data carries (`title_nl`,
|
|
309
|
-
`title_en`, `title_fr`, `title_und`, …), accents preserved. Display is stored
|
|
310
|
-
`index: false` (on disk, off RAM), so extra languages cost nothing – a value
|
|
311
|
-
in a language outside `locales`, or an untagged one, still renders rather than
|
|
312
|
-
collapsing to a bare IRI. One un-indexed regex field (`${name}_<lang>`, see
|
|
313
|
-
`displayFieldPattern`) captures them all;
|
|
314
|
-
- `searchable` → `title_search_nl`/`title_search_en` (folded; one field per
|
|
315
|
-
locale lets a query `query_by` them and rank the user’s language higher, and
|
|
316
|
-
lets a language that needs a dedicated tokenizer set its own stemming `locale`
|
|
317
|
-
in the engine schema);
|
|
318
|
-
- `sortable` → `title_sort_nl`/`title_sort_en` (folded, so a locale-switching UI
|
|
319
|
-
sorts on the active language).
|
|
320
|
-
|
|
321
|
-
A field with `searchable` but no `output` is **search-only** – folded and stemmed
|
|
322
|
-
for retrieval but never rendered (e.g. a creator searched here but shown via a
|
|
323
|
-
separate label). **Only listed locales are indexed** (searched and sorted); a
|
|
324
|
-
literal whose language tag is not in `locales` is still **displayed** but not
|
|
325
|
-
matched or sorted on. Display fields are **omitted, never empty**, when a document
|
|
326
|
-
lacks that language, and the per-locale search/sort fields likewise, so declare
|
|
327
|
-
them optional in the engine schema and sort with `missing_values: last`. A
|
|
328
|
-
deployment that wants to bound the displayed languages narrows them upstream
|
|
329
|
-
(e.g. selecting a language subset in its CONSTRUCT query), since preservation is
|
|
330
|
-
the default.
|
|
331
|
-
|
|
332
|
-
Folding the search fields is what lets diacritic-insensitive matching and
|
|
333
|
-
stemming coexist. A search engine on its **default** locale typically folds case
|
|
334
|
-
and diacritics for you; enabling a language’s **stemming** switches it to ICU
|
|
335
|
-
tokenization, which **preserves** diacritics – at which point `fold()` (from
|
|
336
|
-
[`@lde/text-normalization`](../text-normalization)) is what keeps matching
|
|
337
|
-
diacritic-insensitive. Stemming is rules-based and can mangle proper nouns (the
|
|
338
|
-
Dutch stemmer reduces the city `Bergen` to `berg`), so enable it on free-text
|
|
339
|
-
fields and keep proper-noun facets on a separate, unstemmed field.
|
|
340
|
-
|
|
341
|
-
## Querying
|
|
342
|
-
|
|
343
|
-
The search fields are stored already case- and diacritic-folded, so **the query
|
|
344
|
-
must be folded the same way** with the same `fold()` before it reaches the engine,
|
|
345
|
-
or index and query normalize differently and matches silently miss. This contract
|
|
346
|
-
holds for **any** consumer, including an API built on this package – which is why
|
|
347
|
-
engine adapters and surfaces compile through the shared `SearchQuery` IR and the
|
|
348
|
-
`physicalFields` convention rather than re-deriving field names.
|
|
349
|
-
|
|
350
|
-
Queries are **always validated**: the port contract requires every engine
|
|
351
|
-
adapter to reject a structurally invalid `SearchQuery` (`assertValidQuery`) –
|
|
352
|
-
unknown or non-`filterable` fields in `where`, an operator not matching the
|
|
353
|
-
field’s kind, non-`facetable` facet requests – no matter which surface or
|
|
354
|
-
policy produced it. A typed surface like GraphQL makes most of these
|
|
355
|
-
unrepresentable; the port enforces them for everyone else (deployment
|
|
356
|
-
`queryDefaults`, in-process callers, weaker-typed surfaces).
|
|
357
|
-
|
|
358
|
-
## Typed results
|
|
359
|
-
|
|
360
|
-
An engine is **bound to the whole `SearchSchema` at construction** – like
|
|
361
|
-
every other schema consumer (`projectRoots(quads, roots, schema, type)`,
|
|
362
|
-
`buildGraphQLSchema(schema)`): the adapter factory takes the deployment’s
|
|
363
|
-
declaration, so a query can never meet the wrong index, and deployment-level
|
|
364
|
-
concerns (the label cache, cross-type search, facet batching) have one home.
|
|
365
|
-
Where each type physically lives is the **adapter’s** to decide – it derives a
|
|
366
|
-
collection/index name from the type, by its own engine’s naming conventions,
|
|
367
|
-
and a deployment only overrides that where it must. A search names its type per
|
|
368
|
-
call. Because `searchSchema()` captures the declared types as a literal
|
|
369
|
-
tuple, `search()` accepts **only the deployment’s own types** (a foreign type
|
|
370
|
-
is a compile error) and returns facet/document keys typed by the type passed
|
|
371
|
-
– no caller-side generics:
|
|
372
|
-
|
|
373
|
-
```ts
|
|
374
|
-
// No `collections`: each type reads the collection the adapter names it,
|
|
375
|
-
// which is the one its writer builds. Pass `collections` only to override.
|
|
376
|
-
const engine = createTypesenseSearchEngine(client, schema);
|
|
377
|
-
|
|
378
|
-
const result = await engine.search(DATASET, query);
|
|
379
|
-
result.facets.publisher; // typed: only DATASET’s facetable fields are keys
|
|
380
|
-
result.facets.publsher; // compile error (typo)
|
|
381
|
-
result.hits[0].document.title; // typed: only DATASET’s output fields are keys
|
|
382
|
-
await engine.search(OTHER_TYPE, query); // compile error: not in this schema
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
`searchFacets(type, queries)` is the port’s **batch entry point**: several
|
|
386
|
-
facet-only queries – e.g. a faceted listing’s skip-own-filter variants –
|
|
387
|
-
answered in one engine round-trip (Typesense: a single `multi_search`), one
|
|
388
|
-
outcome per query, positionally aligned – its facet map, or an in-place error,
|
|
389
|
-
so one failed query never discards its siblings’ facets. The same schema
|
|
390
|
-
binding, per-query validation and typed facet keys apply to every query in
|
|
391
|
-
the batch.
|
|
392
|
-
|
|
393
|
-
This only works when the types were declared with `defineSearchType` (or
|
|
394
|
-
captured `as const satisfies SearchType`) and composed with `searchSchema()`;
|
|
395
|
-
a plain `: SearchSchema` annotation widens gracefully to string keys.
|
|
396
|
-
`FacetFieldsOf`/`OutputFieldsOf` are exported for annotating your own
|
|
397
|
-
signatures, and `engine.schema` exposes the bound declaration for routing.
|
|
398
|
-
|
|
399
|
-
## Why a declarative model
|
|
15
|
+
## Documentation
|
|
400
16
|
|
|
401
|
-
|
|
402
|
-
`sh:maxCount`, `required` is `sh:minCount`, `locales` is `sh:languageIn`, `ref`
|
|
403
|
-
is `sh:class`/`sh:node`. So the same core that runs a hand-written `SearchSchema`
|
|
404
|
-
today will run a **SHACL-generated** one tomorrow – the model, the ports and the
|
|
405
|
-
IR stay; only schema-authoring gets automated.
|
|
17
|
+
See the [full documentation](https://ldelements.org/reference/search).
|
package/dist/adapter.d.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* deployment author declaring a schema and running searches needs none of
|
|
7
7
|
* this; the main entry point carries that (small) authoring surface.
|
|
8
8
|
*/
|
|
9
|
-
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
9
|
+
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, ID_FIELD, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
10
10
|
export type { PhysicalFields } from './schema.js';
|
|
11
11
|
export { physicalNameTokens } from './physical-name.js';
|
|
12
|
-
export { filterOperatorFor, filterOperator, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
|
12
|
+
export { filterOperatorFor, filterOperator, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
|
13
13
|
export type { FilterOperator, QueryIssue } from './query.js';
|
|
14
14
|
//# sourceMappingURL=adapter.d.ts.map
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,OAAO,EACP,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,UAAU,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,eAAe,EACf,aAAa,EACb,gBAAgB,EAChB,aAAa,GACd,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
|
package/dist/adapter.js
CHANGED
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
* deployment author declaring a schema and running searches needs none of
|
|
7
7
|
* this; the main entry point carries that (small) authoring surface.
|
|
8
8
|
*/
|
|
9
|
-
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
9
|
+
export { assertTypeInSchema, physicalFields, irAlias, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, isInternalField, isInlineReference, referenceFields, referenceTypeNamed, inlineFramingDepth, fieldNamed, ID_FIELD, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
|
|
10
10
|
export { physicalNameTokens } from './physical-name.js';
|
|
11
|
-
export { filterOperatorFor, filterOperator, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
|
11
|
+
export { filterOperatorFor, filterOperator, isUnsatisfiable, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { projectRoots } from './project.js';
|
|
2
2
|
export type { SearchDocument } from './project.js';
|
|
3
|
-
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, } from './schema.js';
|
|
3
|
+
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, } from './schema.js';
|
|
4
4
|
export type { FieldKind, SearchField, SearchFieldBase, TextField, KeywordField, ReferenceField, NumericField, BooleanField, SearchType, SearchTypeBase, RootType, ReferenceType, RootTypeOf, SearchTypeIssue, SearchSchema, FacetRange, } from './schema.js';
|
|
5
5
|
export type { SearchQuery, Filter, Sort } from './query.js';
|
|
6
6
|
export type { SearchEngine, SearchResult, SearchHit, ResultDocument, SearchValue, LocalizedValue, Reference, FacetBucket, FacetMap, FacetsOutcome, FacetFieldsOf, OutputFieldsOf, } from './engine.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAKnD,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAKnD,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,QAAQ,GACT,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,UAAU,EACV,eAAe,EACf,YAAY,EACZ,UAAU,GACX,MAAM,aAAa,CAAC;AAIrB,YAAY,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAI5D,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,cAAc,EACd,WAAW,EACX,cAAc,EACd,SAAS,EACT,WAAW,EACX,QAAQ,EACR,aAAa,EACb,aAAa,EACb,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -10,4 +10,4 @@ export { projectRoots } from './project.js';
|
|
|
10
10
|
// Unified field model: one declaration drives projection, engine collection
|
|
11
11
|
// schema, query semantics and the GraphQL surface – a discriminated union by
|
|
12
12
|
// `kind`, validated again at runtime when the schema is built.
|
|
13
|
-
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, } from './schema.js';
|
|
13
|
+
export { defineSearchType, searchSchema, validateSearchType, assertValidSearchType, ID_FIELD, } from './schema.js';
|
package/dist/query.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface SearchQuery {
|
|
|
24
24
|
* One `where` clause. The operator is fixed by the target field’s {@link FieldKind}
|
|
25
25
|
* ({@link filterOperatorFor}): keyword/reference use `in` (OR within the field),
|
|
26
26
|
* the numeric/date kinds use an inclusive `range`, boolean uses `is`. Bounds are
|
|
27
|
-
* inclusive only
|
|
27
|
+
* inclusive only – no `gt`/`gte`/`lt`/`lte`.
|
|
28
28
|
*/
|
|
29
29
|
export type Filter = {
|
|
30
30
|
readonly field: string;
|
|
@@ -49,11 +49,28 @@ export type { FilterOperator } from './schema.js';
|
|
|
49
49
|
import type { FilterOperator } from './schema.js';
|
|
50
50
|
/** The operator a {@link Filter} value carries, from its discriminating key. */
|
|
51
51
|
export declare function filterOperator(filter: Filter): FilterOperator;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the query asks for nothing, so no engine can answer it with a hit.
|
|
54
|
+
* True for an empty `in` on {@link ID_FIELD} – and only there, deliberately:
|
|
55
|
+
*
|
|
56
|
+
* An **identity** filter enumerates the documents wanted, so the empty set
|
|
57
|
+
* wants none. A **value** filter constrains a dimension, so an empty set
|
|
58
|
+
* constrains nothing – which is why every other vacuous clause is a no-op the
|
|
59
|
+
* compilers skip (a facet UI with nothing selected sends exactly that). The
|
|
60
|
+
* readings differ because the fields differ in kind, and only for `id` would
|
|
61
|
+
* “no constraint” hand back the whole collection to a caller who asked for
|
|
62
|
+
* specific things – the likely shape being a client mapping a possibly-empty
|
|
63
|
+
* reference array (`id: { in: doc.publisher }`) into a batch lookup.
|
|
64
|
+
*
|
|
65
|
+
* An adapter MUST answer an unsatisfiable query with an empty result rather
|
|
66
|
+
* than dispatching it.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isUnsatisfiable(query: SearchQuery): boolean;
|
|
52
69
|
/**
|
|
53
70
|
* One structural problem {@link validateQuery} found: the query references a
|
|
54
71
|
* field the search type does not declare, or uses it in a role it does not
|
|
55
72
|
* opt into. Vacuous-but-valid clauses (an empty `in` list, a `range` with no
|
|
56
|
-
* bound) are NOT issues
|
|
73
|
+
* bound) are NOT issues – a compiler skips those as no-ops.
|
|
57
74
|
*/
|
|
58
75
|
export interface QueryIssue {
|
|
59
76
|
readonly part: 'where' | 'facets' | 'orderBy';
|
|
@@ -63,14 +80,15 @@ export interface QueryIssue {
|
|
|
63
80
|
/**
|
|
64
81
|
* Structurally validate a query against its search type: every `where` clause
|
|
65
82
|
* targets a declared, `filterable` field with the operator its kind accepts
|
|
66
|
-
* ({@link filterOperatorFor})
|
|
83
|
+
* ({@link filterOperatorFor}) – or the undeclared, always-filterable
|
|
84
|
+
* {@link ID_FIELD}, which takes `in`; every requested facet is a declared, `facetable`
|
|
67
85
|
* field; every sort is `relevance` or a declared field. Sorting deliberately
|
|
68
86
|
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
69
87
|
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
70
88
|
*
|
|
71
89
|
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
72
90
|
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
73
|
-
* garbage to its engine, so validation holds for every caller
|
|
91
|
+
* garbage to its engine, so validation holds for every caller – including
|
|
74
92
|
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
75
93
|
*/
|
|
76
94
|
export declare function validateQuery(query: SearchQuery, searchType: SearchType): readonly QueryIssue[];
|
|
@@ -78,7 +96,7 @@ export declare function validateQuery(query: SearchQuery, searchType: SearchType
|
|
|
78
96
|
* naming every issue. The always-on entry point for engine adapters. */
|
|
79
97
|
export declare function assertValidQuery(query: SearchQuery, searchType: SearchType): void;
|
|
80
98
|
/**
|
|
81
|
-
* The 1-based page an `offset` falls on
|
|
99
|
+
* The 1-based page an `offset` falls on – the numbered-pagination presentation
|
|
82
100
|
* of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
|
|
83
101
|
* query) fetches no hits and has no meaningful page, so it pins to 1 rather
|
|
84
102
|
* than dividing by zero.
|
package/dist/query.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAErB;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,yBAAyB;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,SAAS,IAAI,EAAE,CAAC;IAClC,2BAA2B;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GACd;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC1D;IACE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;KAChC,CAAC;CACH,GACD;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC;AAErD,+BAA+B;AAC/B,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;CACpC;AAKD,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAE7D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAK3D;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EACb,eAAe,GAAG,gBAAgB,GAAG,mBAAmB,GAAG,eAAe,CAAC;CAC9E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,SAAS,UAAU,EAAE,CA0DvB;AAED;yEACyE;AACzE,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,IAAI,CAUN;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE"}
|
package/dist/query.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fieldNamed, filterOperatorFor } from './schema.js';
|
|
1
|
+
import { fieldNamed, filterOperatorFor, ID_FIELD, } from './schema.js';
|
|
2
2
|
// The kind→operator table lives with the field model in schema.ts (one source
|
|
3
3
|
// for the query IR, the surfaces, the adapters AND declaration validation);
|
|
4
4
|
// re-exported here so the query-IR module stays the natural import site.
|
|
@@ -7,22 +7,55 @@ export { filterOperatorFor } from './schema.js';
|
|
|
7
7
|
export function filterOperator(filter) {
|
|
8
8
|
return 'in' in filter ? 'in' : 'range' in filter ? 'range' : 'is';
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether the query asks for nothing, so no engine can answer it with a hit.
|
|
12
|
+
* True for an empty `in` on {@link ID_FIELD} – and only there, deliberately:
|
|
13
|
+
*
|
|
14
|
+
* An **identity** filter enumerates the documents wanted, so the empty set
|
|
15
|
+
* wants none. A **value** filter constrains a dimension, so an empty set
|
|
16
|
+
* constrains nothing – which is why every other vacuous clause is a no-op the
|
|
17
|
+
* compilers skip (a facet UI with nothing selected sends exactly that). The
|
|
18
|
+
* readings differ because the fields differ in kind, and only for `id` would
|
|
19
|
+
* “no constraint” hand back the whole collection to a caller who asked for
|
|
20
|
+
* specific things – the likely shape being a client mapping a possibly-empty
|
|
21
|
+
* reference array (`id: { in: doc.publisher }`) into a batch lookup.
|
|
22
|
+
*
|
|
23
|
+
* An adapter MUST answer an unsatisfiable query with an empty result rather
|
|
24
|
+
* than dispatching it.
|
|
25
|
+
*/
|
|
26
|
+
export function isUnsatisfiable(query) {
|
|
27
|
+
return query.where.some((filter) => filter.field === ID_FIELD && 'in' in filter && filter.in.length === 0);
|
|
28
|
+
}
|
|
10
29
|
/**
|
|
11
30
|
* Structurally validate a query against its search type: every `where` clause
|
|
12
31
|
* targets a declared, `filterable` field with the operator its kind accepts
|
|
13
|
-
* ({@link filterOperatorFor})
|
|
32
|
+
* ({@link filterOperatorFor}) – or the undeclared, always-filterable
|
|
33
|
+
* {@link ID_FIELD}, which takes `in`; every requested facet is a declared, `facetable`
|
|
14
34
|
* field; every sort is `relevance` or a declared field. Sorting deliberately
|
|
15
35
|
* checks declaration only, not the `sortable` flag: that flag means *publicly
|
|
16
36
|
* selectable*, and a deployment policy may sort on a private tie-break field.
|
|
17
37
|
*
|
|
18
38
|
* This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
|
|
19
39
|
* reject a query with issues ({@link assertValidQuery}) instead of passing
|
|
20
|
-
* garbage to its engine, so validation holds for every caller
|
|
40
|
+
* garbage to its engine, so validation holds for every caller – including
|
|
21
41
|
* `queryDefaults` policies and surfaces weaker than GraphQL.
|
|
22
42
|
*/
|
|
23
43
|
export function validateQuery(query, searchType) {
|
|
24
44
|
const issues = [];
|
|
25
45
|
for (const filter of query.where) {
|
|
46
|
+
// `id` is filterable on every type without being declared by any: it is the
|
|
47
|
+
// document’s IRI, so the lookup exists wherever documents do. Membership
|
|
48
|
+
// only – an IRI has no range and no truth value.
|
|
49
|
+
if (filter.field === ID_FIELD) {
|
|
50
|
+
if (filterOperator(filter) !== 'in') {
|
|
51
|
+
issues.push({
|
|
52
|
+
part: 'where',
|
|
53
|
+
field: filter.field,
|
|
54
|
+
reason: 'operator-mismatch',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
26
59
|
const field = fieldNamed(searchType, filter.field);
|
|
27
60
|
if (field === undefined) {
|
|
28
61
|
issues.push({
|
|
@@ -79,7 +112,7 @@ export function assertValidQuery(query, searchType) {
|
|
|
79
112
|
}
|
|
80
113
|
}
|
|
81
114
|
/**
|
|
82
|
-
* The 1-based page an `offset` falls on
|
|
115
|
+
* The 1-based page an `offset` falls on – the numbered-pagination presentation
|
|
83
116
|
* of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
|
|
84
117
|
* query) fetches no hits and has no meaningful page, so it pins to 1 rather
|
|
85
118
|
* than dividing by zero.
|
package/dist/schema.d.ts
CHANGED
|
@@ -144,9 +144,13 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
|
|
|
144
144
|
/** Logical API type name of the referenced entity (PascalCase, e.g.
|
|
145
145
|
* `Organization`) – names the reference’s type in the API surfaces, the
|
|
146
146
|
* way {@link SearchType.name} names a root type; fields sharing it share
|
|
147
|
-
* one emitted type.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* one emitted type. For a `labelOnly`/`idOnly` reference it is a name,
|
|
148
|
+
* not a key: it need not correspond to any indexed root type, and it may
|
|
149
|
+
* name one (`creator` → `Person`, with the labels resolved from that
|
|
150
|
+
* root’s collection via `labelSource`) – an API surface then serves the
|
|
151
|
+
* reference under a derived name (GraphQL: `‹Name›Reference`) to keep
|
|
152
|
+
* type names unique. An `inline` reference’s typeName instead resolves to
|
|
153
|
+
* a declared {@link ReferenceType}, so it can never name a root type. */
|
|
150
154
|
readonly typeName: string;
|
|
151
155
|
/** How much of the referenced entity the reference carries. `labelOnly`
|
|
152
156
|
* (id + display label, resolved from a label source) and `inline` (the
|
|
@@ -337,8 +341,21 @@ export declare function labelFieldOf(searchType: SearchType): TextField | undefi
|
|
|
337
341
|
*/
|
|
338
342
|
export interface SearchTypeIssue {
|
|
339
343
|
readonly field: string;
|
|
340
|
-
readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'text-not-filterable' | 'text-not-facetable';
|
|
344
|
+
readonly reason: 'duplicate-field-name' | 'invalid-field-name' | 'unknown-kind' | 'invalid-locale' | 'missing-ref' | 'ref-not-allowed' | 'text-requires-locales' | 'locales-not-allowed' | 'facet-ranges-not-allowed' | 'searchable-not-allowed' | 'transform-not-allowed' | 'derive-with-path' | 'text-not-filterable' | 'text-not-facetable' | 'reserved-field-name';
|
|
341
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* The reserved logical name of a document’s IRI – the one field every indexed
|
|
348
|
+
* thing carries, and the only one no {@link SearchType} declares. It is the
|
|
349
|
+
* hit’s identity ({@link SearchHit.id}), not a value in its
|
|
350
|
+
* {@link ResultDocument}, so it is surfaced and filtered by name alone.
|
|
351
|
+
*
|
|
352
|
+
* `id` answers *what the thing is*; a domain field like `schema:identifier`
|
|
353
|
+
* answers *what a source system calls it* (a catalogue or record number). Those
|
|
354
|
+
* are different questions, so a declaration may still carry an `identifier`
|
|
355
|
+
* field of its own – but never an `id` one, which
|
|
356
|
+
* {@link validateSearchType} rejects as `reserved-field-name`.
|
|
357
|
+
*/
|
|
358
|
+
export declare const ID_FIELD = "id";
|
|
342
359
|
/**
|
|
343
360
|
* Structurally validate one {@link SearchType} declaration – the
|
|
344
361
|
* declaration-time counterpart of `validateQuery`. Rules:
|
|
@@ -346,7 +363,7 @@ export interface SearchTypeIssue {
|
|
|
346
363
|
* - field names are unique (a duplicate would silently shadow in every
|
|
347
364
|
* consumer, each picking a different winner) and a metacharacter-free
|
|
348
365
|
* identifier (the name is interpolated into physical field names and the
|
|
349
|
-
* display RE2 pattern);
|
|
366
|
+
* display RE2 pattern), and is not the reserved {@link ID_FIELD};
|
|
350
367
|
* - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
|
|
351
368
|
* reserved name↔locale separator);
|
|
352
369
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
package/dist/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
|
package/dist/schema.js
CHANGED
|
@@ -210,6 +210,19 @@ function assertResolvableLabelSources(types) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The reserved logical name of a document’s IRI – the one field every indexed
|
|
215
|
+
* thing carries, and the only one no {@link SearchType} declares. It is the
|
|
216
|
+
* hit’s identity ({@link SearchHit.id}), not a value in its
|
|
217
|
+
* {@link ResultDocument}, so it is surfaced and filtered by name alone.
|
|
218
|
+
*
|
|
219
|
+
* `id` answers *what the thing is*; a domain field like `schema:identifier`
|
|
220
|
+
* answers *what a source system calls it* (a catalogue or record number). Those
|
|
221
|
+
* are different questions, so a declaration may still carry an `identifier`
|
|
222
|
+
* field of its own – but never an `id` one, which
|
|
223
|
+
* {@link validateSearchType} rejects as `reserved-field-name`.
|
|
224
|
+
*/
|
|
225
|
+
export const ID_FIELD = 'id';
|
|
213
226
|
/** Kinds that can feed full-text search (project a folded search field). */
|
|
214
227
|
const SEARCHABLE_KINDS = ['text', 'keyword', 'reference'];
|
|
215
228
|
/** Kinds whose projection applies the {@link KeywordField.transform}. */
|
|
@@ -237,7 +250,7 @@ const LOCALE_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
|
|
|
237
250
|
* - field names are unique (a duplicate would silently shadow in every
|
|
238
251
|
* consumer, each picking a different winner) and a metacharacter-free
|
|
239
252
|
* identifier (the name is interpolated into physical field names and the
|
|
240
|
-
* display RE2 pattern);
|
|
253
|
+
* display RE2 pattern), and is not the reserved {@link ID_FIELD};
|
|
241
254
|
* - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
|
|
242
255
|
* reserved name↔locale separator);
|
|
243
256
|
* - a `reference` field that is `output` declares `ref` (the API surfaces
|
|
@@ -277,6 +290,12 @@ export function validateSearchType(searchType) {
|
|
|
277
290
|
if (!FIELD_NAME_PATTERN.test(field.name)) {
|
|
278
291
|
issue('invalid-field-name');
|
|
279
292
|
}
|
|
293
|
+
// `id` is the document’s IRI, surfaced and filtered for every type; a
|
|
294
|
+
// declared field of that name would shadow it in the API output and in
|
|
295
|
+
// `where`, silently answering a different question.
|
|
296
|
+
if (field.name === ID_FIELD) {
|
|
297
|
+
issue('reserved-field-name');
|
|
298
|
+
}
|
|
280
299
|
// Every kind-dependent rule below would silently pass for a kind outside
|
|
281
300
|
// the union, so a typo’d kind in a plain-JS declaration must fail here.
|
|
282
301
|
if (!Object.hasOwn(OPERATOR_BY_KIND, field.kind)) {
|
package/dist/testing.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ import type { SearchEngine } from './engine.js';
|
|
|
5
5
|
* live instance of itself, so the port rules hold by test rather than by
|
|
6
6
|
* prose. Covers schema binding (a type outside the bound schema is rejected),
|
|
7
7
|
* the always-on query validation (a structurally invalid query is rejected
|
|
8
|
-
* before it reaches the engine)
|
|
9
|
-
* `searchFacets` batch – for every type in the
|
|
8
|
+
* before it reaches the engine), the undeclared `id` lookup, and the result
|
|
9
|
+
* shape of a browse query and a `searchFacets` batch – for every type in the
|
|
10
|
+
* schema.
|
|
10
11
|
*
|
|
11
12
|
* ```ts
|
|
12
13
|
* describeSearchEngineContract('TypesenseSearchEngine', () => engine);
|
package/dist/testing.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAchD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAuKN"}
|
package/dist/testing.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { filterOperatorFor, } from './query.js';
|
|
3
|
-
import { facetableFields, filterableFields } from './schema.js';
|
|
3
|
+
import { facetableFields, filterableFields, ID_FIELD, } from './schema.js';
|
|
4
4
|
/**
|
|
5
5
|
* The executable {@link SearchEngine} port contract (import from
|
|
6
6
|
* `@lde/search/testing`): a vitest suite every engine adapter runs against a
|
|
7
7
|
* live instance of itself, so the port rules hold by test rather than by
|
|
8
8
|
* prose. Covers schema binding (a type outside the bound schema is rejected),
|
|
9
9
|
* the always-on query validation (a structurally invalid query is rejected
|
|
10
|
-
* before it reaches the engine)
|
|
11
|
-
* `searchFacets` batch – for every type in the
|
|
10
|
+
* before it reaches the engine), the undeclared `id` lookup, and the result
|
|
11
|
+
* shape of a browse query and a `searchFacets` batch – for every type in the
|
|
12
|
+
* schema.
|
|
12
13
|
*
|
|
13
14
|
* ```ts
|
|
14
15
|
* describeSearchEngineContract('TypesenseSearchEngine', () => engine);
|
|
@@ -52,6 +53,22 @@ export function describeSearchEngineContract(name, engine) {
|
|
|
52
53
|
await expect(engine().search(searchType, query)).rejects.toThrow(/unknown-field/);
|
|
53
54
|
}
|
|
54
55
|
});
|
|
56
|
+
it('applies an `id` lookup rather than ignoring the clause', async () => {
|
|
57
|
+
// `id` is filterable on every type without being declared by any, so an
|
|
58
|
+
// adapter that resolves `where` fields through its declaration alone
|
|
59
|
+
// takes the unknown-field path and drops the clause – returning EVERY
|
|
60
|
+
// document where the caller asked for one. Pinned here because that
|
|
61
|
+
// failure is silent: no error, just the wrong result set.
|
|
62
|
+
for (const searchType of types()) {
|
|
63
|
+
const query = {
|
|
64
|
+
...browse(searchType),
|
|
65
|
+
where: [{ field: ID_FIELD, in: ['urn:test:no-such-document'] }],
|
|
66
|
+
};
|
|
67
|
+
const result = await engine().search(searchType, query);
|
|
68
|
+
expect(result.total).toBe(0);
|
|
69
|
+
expect(result.hits).toEqual([]);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
55
72
|
it('rejects a facet request for an unknown field', async () => {
|
|
56
73
|
for (const searchType of types()) {
|
|
57
74
|
const query = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Engine- and domain-agnostic core for RDF-backed search: a unified declarative field model (SearchField/SearchType/SearchSchema), a neutral query IR, the SearchEngine port with logical result types, and a streaming CONSTRUCT-to-document projection. Bakes in no engine, protocol, or domain.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|