@lde/search 0.6.0 → 0.7.1

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 CHANGED
@@ -21,15 +21,15 @@ The library never names your domain: the same core drives a `Dataset`,
21
21
 
22
22
  It provides four things:
23
23
 
24
- - **unified field model** — `SearchField` / `SearchType` / `SearchSchema`:
24
+ - **unified field model** – `SearchField` / `SearchType` / `SearchSchema`:
25
25
  one declaration per field that drives all four consumers below, so they
26
26
  cannot drift;
27
- - **neutral query IR** — `SearchQuery` / `Filter` / `Sort` + filter
27
+ - **neutral query IR** – `SearchQuery` / `Filter` / `Sort` + filter
28
28
  semantics: every API surface compiles into it, every engine adapter compiles
29
29
  out of it, so the two cannot drift;
30
- - **engine port** — `SearchEngine` and the logical result types
30
+ - **engine port** – `SearchEngine` and the logical result types
31
31
  (`SearchResult` / `SearchHit` / `ResultDocument` / `Reference` / …);
32
- - **streaming projection** — `projectGraph`, RDF `CONSTRUCT` quads → flat
32
+ - **streaming projection** – `projectGraph`, RDF `CONSTRUCT` quads → flat
33
33
  search documents.
34
34
 
35
35
  ```
@@ -40,7 +40,7 @@ SearchSchema ─┬─► projection (projectGraph → flat documents)
40
40
  ```
41
41
 
42
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
43
+ data shapes**, each one parameterised by the schema – three chains, meeting at
44
44
  the engine:
45
45
 
46
46
  ```
@@ -51,19 +51,19 @@ results: engine response ──parse──► SearchResult ──shape──
51
51
 
52
52
  Validation happens before the first arrow (SHACL over the RDF) and inside the
53
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.
54
+ a typed, deterministic function – easy to test, and swappable per deployment.
55
55
 
56
56
  ## Entry points
57
57
 
58
58
  Exports are stratified by audience:
59
59
 
60
- - **`@lde/search`** — the authoring surface: `defineSearchType`,
60
+ - **`@lde/search`** – the authoring surface: `defineSearchType`,
61
61
  `searchSchema`, `projectGraph` (+ the IR readers `derive` functions use),
62
62
  validation, and every model/query/result type.
63
- - **`@lde/search/adapter`** — plumbing for engine adapters and API surfaces:
63
+ - **`@lde/search/adapter`** – plumbing for engine adapters and API surfaces:
64
64
  `physicalFields`, the field selectors, `assertValidQuery`, the filter
65
65
  operators and storage codecs.
66
- - **`@lde/search/testing`** — `describeSearchEngineContract`, the executable
66
+ - **`@lde/search/testing`** – `describeSearchEngineContract`, the executable
67
67
  port contract every engine adapter runs against a live instance of itself
68
68
  (vitest; optional peer).
69
69
 
@@ -76,7 +76,7 @@ and GraphQL (one of the surfaces):
76
76
  | -------------- | ---------------------------------------------------------------------------------------------------------------- | -------------- | ----------- |
77
77
  | `SearchField` | One queryable field: a `kind`, the IR `path` it projects from, and the capability flags it opts into | property shape | field |
78
78
  | `SearchType` | One root type’s complete declaration: its logical API `name`, its `class` IRI and its fields (incl. derived) | NodeShape | object type |
79
- | `SearchSchema` | The whole search declaration: every `SearchType`, keyed by `class` IRI — build one with `searchSchema(...types)` | shapes graph | schema |
79
+ | `SearchSchema` | The whole search declaration: every `SearchType`, keyed by `class` IRI – build one with `searchSchema(...types)` | shapes graph | schema |
80
80
 
81
81
  `projectGraph` and the GraphQL surface consume a `SearchSchema` (projecting
82
82
  every type in one pass); the engine port executes one `SearchType` at a time.
@@ -85,12 +85,12 @@ every type in one pass); the engine port executes one `SearchType` at a time.
85
85
 
86
86
  Two conventions hold across the whole family:
87
87
 
88
- - **Parameter order** — a function takes the value it operates on first and
88
+ - **Parameter order** – a function takes the value it operates on first and
89
89
  the declaration right after it: `projectDocument(node, type)`,
90
90
  `buildSearchParams(query, type)`,
91
91
  `createTypesenseSearchEngine(client, schema, options)`,
92
92
  `engine.search(type, query)`.
93
- - **Factory verbs** — the verb tells you what kind of thing comes back.
93
+ - **Factory verbs** – the verb tells you what kind of thing comes back.
94
94
  `define*` captures a declaration as a literal (`defineSearchType`);
95
95
  `build*` is a pure data-to-data constructor (`buildCollectionDefinition`,
96
96
  `buildSearchParams`, `buildGraphQLSchema`); `create*` makes a stateful
@@ -101,7 +101,7 @@ Two conventions hold across the whole family:
101
101
 
102
102
  The mapping is data, not code. Each field declares its `kind`, the IR `path` to
103
103
  read (or a `derive` function for a **derived** field, computed from the framed
104
- node in declaration order — so it may read fields declared before it), and the
104
+ node in declaration order – so it may read fields declared before it), and the
105
105
  capabilities it opts into. The physical field names a declaration fans out to
106
106
  (per-locale search/sort keys) come from
107
107
  `physicalFields`, the single convention projection, the collection definition and the
@@ -119,7 +119,7 @@ const DATASET = defineSearchType({
119
119
  name: 'Dataset', // logical API name: names the GraphQL type, a REST path, …
120
120
  class: 'http://www.w3.org/ns/dcat#Dataset',
121
121
  fields: [
122
- // → title_nl, title_en, title_search_nl/_en, title_sort_nl/_en
122
+ // → title_<lang> (display, every present language), title_search_nl/_en, title_sort_nl/_en
123
123
  {
124
124
  name: 'title',
125
125
  path: 'http://purl.org/dc/terms/title',
@@ -163,33 +163,35 @@ for await (const { searchType, document } of projectGraph(
163
163
 
164
164
  `defineSearchType` captures the declaration as a literal (what
165
165
  `as const satisfies SearchType` would do manually, with nothing to remember),
166
- so typed facet/output keys can be derived from it — see
166
+ so typed facet/output keys can be derived from it – see
167
167
  [Typed results](#typed-results) and `@lde/search-api-graphql`.
168
168
 
169
169
  **Kinds** (`FieldKind`): `text`, `keyword`, `integer`, `number`, `boolean`,
170
170
  `date`, `reference`. The Typesense/engine vocabulary and the GraphQL types are
171
- _derived_ from the kind by the adapter and the surface — never declared here.
171
+ _derived_ from the kind by the adapter and the surface – never declared here.
172
172
 
173
173
  `SearchField` is a **discriminated union by `kind`** (`TextField`,
174
174
  `KeywordField`, `ReferenceField`, `NumericField`, `BooleanField`): each kind
175
- declares exactly the properties it can honour — `locales` on text, `ref` on
176
- references, `facetRanges` on numerics — so an illegal declaration fails to
175
+ declares exactly the properties it can honour – `locales` on text, `ref` on
176
+ references, `facetRanges` on numerics – so an illegal declaration fails to
177
177
  compile. Text is **always multilingual in shape**: `locales` lists the
178
- language tags to fan out, and the reserved **`und`** locale (JSON-LD `@none`,
179
- RDF `und`) buckets untagged literals — a monolingual or untagged corpus
180
- declares `locales: ['und']`, mixed data `['nl', 'und']`. Declaring a real
181
- language is recommended (it drives per-locale stemming); `und` is folded but
182
- unstemmed unless `defaultLocale` opts in, is never demoted in search
183
- weighting, and adding a language later is additive — the API output shape
184
- never changes. Use `keyword` for exact-match tokens, never for prose.
178
+ language tags to **index** (search/sort), and the reserved **`und`** locale
179
+ (JSON-LD `@none`, RDF `und`) buckets untagged literals – a monolingual or
180
+ untagged corpus declares `locales: ['und']`, mixed data `['nl', 'und']`.
181
+ Display, by contrast, keeps every language present, not only the listed ones
182
+ (see [Locales](#locales)). Declaring a real language is recommended (it drives
183
+ per-locale stemming); `und` is folded but unstemmed unless `defaultLocale` opts
184
+ in, is never demoted in search weighting, and adding a language later is
185
+ additive – the API output shape never changes. Use `keyword` for exact-match
186
+ tokens, never for prose.
185
187
 
186
188
  **Declarations are also validated at runtime** (for declarations built
187
- outside TypeScript — a SHACL generator, plain JS): `searchSchema()`
189
+ outside TypeScript – a SHACL generator, plain JS): `searchSchema()`
188
190
  rejects a structurally invalid declaration (duplicate field names, an `output`
189
191
  reference without `ref`, `text` without locales, `locales` on a non-text kind,
190
192
  `facetRanges` on a non-numeric kind, `searchable`/`transform` on a kind whose
191
193
  projection cannot honour it, `filterable`/`facetable` on `text`, two types
192
- sharing a `class` IRI or `name`) — the declaration-time counterpart of the
194
+ sharing a `class` IRI or `name`) – the declaration-time counterpart of the
193
195
  port’s always-on query validation, so a bad schema fails at startup rather
194
196
  than per document at index time. `validateSearchType` /
195
197
  `assertValidSearchType` are exported for validating a single declaration
@@ -228,7 +230,7 @@ Each yielded value is a `{ searchType, document }` pair: the whole schema
228
230
  projects into **one** mixed stream, and every document is tagged with the
229
231
  `SearchType` it was framed from. That tag is what lets the write side fan the
230
232
  stream out to per-type collections without re-deriving the type from the
231
- document — see `@lde/search-pipeline`’s multi-collection writer.
233
+ document – see `@lde/search-pipeline`’s multi-collection writer.
232
234
 
233
235
  `projectGraph` **consumes the quads once** – a single scan builds the subject
234
236
  index every type frames off – and so accepts any `Iterable<Quad>`, not just a
@@ -247,30 +249,39 @@ projectGraph(
247
249
 
248
250
  ## Locales
249
251
 
250
- `locales` is the **single** list of locales a `text` field projects (`und` =
251
- untagged literals);
252
- `output`, `searchable` and `sortable` are independent opt-ins that each fan out
253
- over it (so a field emits exactly what it opts into):
254
-
255
- - `output` → `title_nl`/`title_en` (accents preserved);
256
- - `searchable` → `title_search_nl`/`title_search_en` (folded; one field per locale
257
- lets a query `query_by` them and rank the user’s language higher, and lets a
258
- language that needs a dedicated tokenizer set its own stemming `locale` in the
259
- engine schema);
252
+ `locales` declares the languages a `text` field wants **indexed** (`und` =
253
+ untagged literals). It drives the two per-locale, in-memory fanouts –
254
+ `searchable` and `sortable` – but **not** display, which preserves every
255
+ language present:
256
+
257
+ - `output` → `title_<lang>` for **every** language the data carries (`title_nl`,
258
+ `title_en`, `title_fr`, `title_und`, …), accents preserved. Display is stored
259
+ `index: false` (on disk, off RAM), so extra languages cost nothing – a value
260
+ in a language outside `locales`, or an untagged one, still renders rather than
261
+ collapsing to a bare IRI. One un-indexed regex field (`${name}_<lang>`, see
262
+ `displayFieldPattern`) captures them all;
263
+ - `searchable` → `title_search_nl`/`title_search_en` (folded; one field per
264
+ locale lets a query `query_by` them and rank the user’s language higher, and
265
+ lets a language that needs a dedicated tokenizer set its own stemming `locale`
266
+ in the engine schema);
260
267
  - `sortable` → `title_sort_nl`/`title_sort_en` (folded, so a locale-switching UI
261
268
  sorts on the active language).
262
269
 
263
- A field with `searchable` but no `output` is **search-only** — folded and stemmed
270
+ A field with `searchable` but no `output` is **search-only** – folded and stemmed
264
271
  for retrieval but never rendered (e.g. a creator searched here but shown via a
265
- separate label). **Only listed locales are indexed**; a literal whose language tag is not in
266
- `locales` is not projected (declare `und` to catch untagged literals). Per-locale fields are
267
- **omitted, never empty**, when a document lacks that language, so declare them
268
- optional in the engine schema and sort with `missing_values: last`.
272
+ separate label). **Only listed locales are indexed** (searched and sorted); a
273
+ literal whose language tag is not in `locales` is still **displayed** but not
274
+ matched or sorted on. Display fields are **omitted, never empty**, when a document
275
+ lacks that language, and the per-locale search/sort fields likewise, so declare
276
+ them optional in the engine schema and sort with `missing_values: last`. A
277
+ deployment that wants to bound the displayed languages narrows them upstream
278
+ (e.g. selecting a language subset in its CONSTRUCT query), since preservation is
279
+ the default.
269
280
 
270
281
  Folding the search fields is what lets diacritic-insensitive matching and
271
282
  stemming coexist. A search engine on its **default** locale typically folds case
272
283
  and diacritics for you; enabling a language’s **stemming** switches it to ICU
273
- tokenization, which **preserves** diacritics — at which point `fold()` (from
284
+ tokenization, which **preserves** diacritics – at which point `fold()` (from
274
285
  [`@lde/text-normalization`](../text-normalization)) is what keeps matching
275
286
  diacritic-insensitive. Stemming is rules-based and can mangle proper nouns (the
276
287
  Dutch stemmer reduces the city `Bergen` to `berg`), so enable it on free-text
@@ -281,35 +292,37 @@ fields and keep proper-noun facets on a separate, unstemmed field.
281
292
  The search fields are stored already case- and diacritic-folded, so **the query
282
293
  must be folded the same way** with the same `fold()` before it reaches the engine,
283
294
  or index and query normalize differently and matches silently miss. This contract
284
- holds for **any** consumer, including an API built on this package — which is why
295
+ holds for **any** consumer, including an API built on this package – which is why
285
296
  engine adapters and surfaces compile through the shared `SearchQuery` IR and the
286
297
  `physicalFields` convention rather than re-deriving field names.
287
298
 
288
299
  Queries are **always validated**: the port contract requires every engine
289
- adapter to reject a structurally invalid `SearchQuery` (`assertValidQuery`) —
300
+ adapter to reject a structurally invalid `SearchQuery` (`assertValidQuery`) –
290
301
  unknown or non-`filterable` fields in `where`, an operator not matching the
291
- field’s kind, non-`facetable` facet requests — no matter which surface or
302
+ field’s kind, non-`facetable` facet requests – no matter which surface or
292
303
  policy produced it. A typed surface like GraphQL makes most of these
293
304
  unrepresentable; the port enforces them for everyone else (deployment
294
305
  `queryDefaults`, in-process callers, weaker-typed surfaces).
295
306
 
296
307
  ## Typed results
297
308
 
298
- An engine is **bound to the whole `SearchSchema` at construction** — like
309
+ An engine is **bound to the whole `SearchSchema` at construction** – like
299
310
  every other schema consumer (`projectGraph(quads, schema)`,
300
311
  `buildGraphQLSchema(schema)`): the adapter factory takes the deployment’s
301
- declaration together with each type’s physical location, so a query can never
302
- meet the wrong index, and deployment-level concerns (the label cache,
303
- cross-type search, facet batching) have one home. A search names its type per
312
+ declaration, so a query can never meet the wrong index, and deployment-level
313
+ concerns (the label cache, cross-type search, facet batching) have one home.
314
+ Where each type physically lives is the **adapter’s** to decide – it derives a
315
+ collection/index name from the type, by its own engine’s naming conventions,
316
+ and a deployment only overrides that where it must. A search names its type per
304
317
  call. Because `searchSchema()` captures the declared types as a literal
305
318
  tuple, `search()` accepts **only the deployment’s own types** (a foreign type
306
319
  is a compile error) and returns facet/document keys typed by the type passed
307
- — no caller-side generics:
320
+ – no caller-side generics:
308
321
 
309
322
  ```ts
310
- const engine = createTypesenseSearchEngine(client, schema, {
311
- collections: { Dataset: 'datasets', Person: 'people' }, // omitting one = type error
312
- });
323
+ // No `collections`: each type reads the collection the adapter names it,
324
+ // which is the one its writer builds. Pass `collections` only to override.
325
+ const engine = createTypesenseSearchEngine(client, schema);
313
326
 
314
327
  const result = await engine.search(DATASET, query);
315
328
  result.facets.publisher; // typed: only DATASET’s facetable fields are keys
@@ -337,5 +350,5 @@ signatures, and `engine.schema` exposes the bound declaration for routing.
337
350
  The vocabulary mirrors SHACL on purpose: `path` is `sh:path`, `array` is
338
351
  `sh:maxCount`, `required` is `sh:minCount`, `locales` is `sh:languageIn`, `ref`
339
352
  is `sh:class`/`sh:node`. So the same core that runs a hand-written `SearchSchema`
340
- today will run a **SHACL-generated** one tomorrow — the model, the ports and the
353
+ today will run a **SHACL-generated** one tomorrow – the model, the ports and the
341
354
  IR stay; only schema-authoring gets automated.
package/dist/adapter.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * The adapter-author surface of `@lde/search` (import from
3
3
  * `@lde/search/adapter`): the plumbing an engine adapter or API surface needs
4
- * to compile queries and reconstruct results — field selectors, the physical
4
+ * to compile queries and reconstruct results – field selectors, the physical
5
5
  * field-name convention, query validation and the storage codecs. A
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, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, referenceFields, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
9
+ export { assertTypeInSchema, physicalFields, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, referenceFields, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
10
10
  export type { PhysicalFields } from './schema.js';
11
+ export { physicalNameTokens } from './physical-name.js';
11
12
  export { filterOperatorFor, filterOperator, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
12
13
  export type { FilterOperator, QueryIssue } from './query.js';
13
14
  //# sourceMappingURL=adapter.d.ts.map
@@ -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,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,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,gBAAgB,EAChB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,eAAe,EACf,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"}
package/dist/adapter.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The adapter-author surface of `@lde/search` (import from
3
3
  * `@lde/search/adapter`): the plumbing an engine adapter or API surface needs
4
- * to compile queries and reconstruct results — field selectors, the physical
4
+ * to compile queries and reconstruct results – field selectors, the physical
5
5
  * field-name convention, query validation and the storage codecs. A
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, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, referenceFields, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
9
+ export { assertTypeInSchema, physicalFields, displayFieldName, displayFieldPattern, displayLangOf, searchableFields, facetableFields, filterableFields, sortableFields, outputFields, referenceFields, fieldNamed, labelFieldOf, isRangeFacet, isoToUnixSeconds, unixSecondsToIso, } from './schema.js';
10
+ export { physicalNameTokens } from './physical-name.js';
10
11
  export { filterOperatorFor, filterOperator, validateQuery, assertValidQuery, pageForOffset, } from './query.js';
@@ -0,0 +1,31 @@
1
+ import type { SearchType } from './schema.js';
2
+ /**
3
+ * The engine-neutral word tokens an adapter formats into the physical name of a
4
+ * {@link SearchType}’s container – a Typesense collection, an Elasticsearch or
5
+ * OpenSearch index: the type’s `name` split into words, lowercased, with the
6
+ * last word pluralized (`CreativeWork` → `['creative', 'works']`).
7
+ *
8
+ * Both engines document the same convention – name a container after the plural
9
+ * of what it holds – and differ only in how they join the words: Typesense
10
+ * snake_case (`blog_articles`), Elasticsearch/OpenSearch kebab-case
11
+ * (`blog-articles`). So the split and the inflection are shared here, and only
12
+ * the join and the engine’s own legality rules live in the adapter – which is
13
+ * why this returns tokens rather than a formatted name, and why it is spelled
14
+ * neutrally (‘container’, not ‘collection’ or ‘index’).
15
+ *
16
+ * Acronyms stay whole (`DCATDataset` → `['dcat', 'datasets']`, `TVSeries` →
17
+ * `['tv', 'series']`), which is why the split is hand-rolled rather than
18
+ * delegated to an inflector’s `underscore`/`tableize` – those split on every
19
+ * capital (`t_v_series`).
20
+ *
21
+ * Pluralization is a real inflector, so irregular and invariant nouns land the
22
+ * way the engines’ own docs write them (`Person` → `people`, `TVSeries` →
23
+ * `tv_series`, `Analysis` → `analyses`) rather than as the non-words regular
24
+ * rules produce (`serieses`, `analysises`). An already-plural name is
25
+ * idempotent (`People` → `people`).
26
+ *
27
+ * Returns an empty array when `name` carries no alphanumerics at all; the
28
+ * adapter decides what that means, since only it knows its engine’s rules.
29
+ */
30
+ export declare function physicalNameTokens(searchType: SearchType): readonly string[];
31
+ //# sourceMappingURL=physical-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"physical-name.d.ts","sourceRoot":"","sources":["../src/physical-name.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAK5E"}
@@ -0,0 +1,44 @@
1
+ import { pluralize } from 'inflection';
2
+ /**
3
+ * The engine-neutral word tokens an adapter formats into the physical name of a
4
+ * {@link SearchType}’s container – a Typesense collection, an Elasticsearch or
5
+ * OpenSearch index: the type’s `name` split into words, lowercased, with the
6
+ * last word pluralized (`CreativeWork` → `['creative', 'works']`).
7
+ *
8
+ * Both engines document the same convention – name a container after the plural
9
+ * of what it holds – and differ only in how they join the words: Typesense
10
+ * snake_case (`blog_articles`), Elasticsearch/OpenSearch kebab-case
11
+ * (`blog-articles`). So the split and the inflection are shared here, and only
12
+ * the join and the engine’s own legality rules live in the adapter – which is
13
+ * why this returns tokens rather than a formatted name, and why it is spelled
14
+ * neutrally (‘container’, not ‘collection’ or ‘index’).
15
+ *
16
+ * Acronyms stay whole (`DCATDataset` → `['dcat', 'datasets']`, `TVSeries` →
17
+ * `['tv', 'series']`), which is why the split is hand-rolled rather than
18
+ * delegated to an inflector’s `underscore`/`tableize` – those split on every
19
+ * capital (`t_v_series`).
20
+ *
21
+ * Pluralization is a real inflector, so irregular and invariant nouns land the
22
+ * way the engines’ own docs write them (`Person` → `people`, `TVSeries` →
23
+ * `tv_series`, `Analysis` → `analyses`) rather than as the non-words regular
24
+ * rules produce (`serieses`, `analysises`). An already-plural name is
25
+ * idempotent (`People` → `people`).
26
+ *
27
+ * Returns an empty array when `name` carries no alphanumerics at all; the
28
+ * adapter decides what that means, since only it knows its engine’s rules.
29
+ */
30
+ export function physicalNameTokens(searchType) {
31
+ const words = searchType.name.match(NAME_WORD_PATTERN) ?? [];
32
+ const tokens = words.map((word) => word.toLowerCase());
33
+ const last = tokens.at(-1);
34
+ return last === undefined ? [] : [...tokens.slice(0, -1), pluralize(last)];
35
+ }
36
+ /**
37
+ * One word of a PascalCase/camelCase name, in precedence order: a run of
38
+ * capitals not followed by a lowercase letter (an acronym – `DCAT` in
39
+ * `DCATDataset`, `TV` in `TVSeries`); a capital starting an ordinary word; or a
40
+ * lowercase run. Anything else (a separator, punctuation) matches nothing and
41
+ * is dropped, so a name already written as `creative_work` or `creative-work`
42
+ * tokenizes the same way.
43
+ */
44
+ const NAME_WORD_PATTERN = /[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g;
package/dist/project.d.ts CHANGED
@@ -7,7 +7,7 @@ export type SearchDocument = {
7
7
  } & Record<string, unknown>;
8
8
  /**
9
9
  * A projected document tagged with the {@link SearchType} it was projected
10
- * from — one element of {@link projectGraph}’s mixed, whole-schema stream. The
10
+ * from – one element of {@link projectGraph}’s mixed, whole-schema stream. The
11
11
  * tag is what lets a multi-collection writer route each document to the
12
12
  * collection for its type without re-deriving the type from the document.
13
13
  */
@@ -26,7 +26,7 @@ export interface TypedSearchDocument {
26
26
  export declare function projectDocument(node: FramedNode, searchType: SearchType): SearchDocument;
27
27
  /**
28
28
  * Frame `quads` for every root type in the schema and project each node with its
29
- * type’s declaration — the multi-shape pipeline. Yields each document tagged
29
+ * type’s declaration – the multi-shape pipeline. Yields each document tagged
30
30
  * with its {@link SearchType} ({@link TypedSearchDocument}), so a downstream
31
31
  * multi-collection writer can route it to that type’s collection; a
32
32
  * single-collection consumer just reads `.document`. Streams one document at a
@@ -1 +1 @@
1
- {"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAML,KAAK,YAAY,EACjB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAErB,+DAA+D;AAC/D,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtE;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;CACnC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,GACrB,cAAc,CAYhB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,EACrB,MAAM,EAAE,YAAY,GACnB,aAAa,CAAC,mBAAmB,CAAC,CAWpC;AAsID,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAInE;AAED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAI/D"}
1
+ {"version":3,"file":"project.d.ts","sourceRoot":"","sources":["../src/project.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAOL,KAAK,YAAY,EACjB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAErB,+DAA+D;AAC/D,MAAM,MAAM,cAAc,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEtE;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;CACnC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,GACrB,cAAc,CAYhB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,EACrB,MAAM,EAAE,YAAY,GACnB,aAAa,CAAC,mBAAmB,CAAC,CAWpC;AAqJD,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAInE;AAED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,MAAM,GACX,MAAM,GAAG,SAAS,CAEpB;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAI/D"}
package/dist/project.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fold } from '@lde/text-normalization';
2
2
  import { buildSubjectIndex, frameSubjects, } from './frame-by-type.js';
3
- import { isoToUnixSeconds, physicalFields, } from './schema.js';
3
+ import { displayFieldName, isoToUnixSeconds, physicalFields, } from './schema.js';
4
4
  /**
5
5
  * Project one framed JSON-LD node into a flat search document: apply each field
6
6
  * of the type in declaration order. A field with a `derive` function computes
@@ -22,7 +22,7 @@ export function projectDocument(node, searchType) {
22
22
  }
23
23
  /**
24
24
  * Frame `quads` for every root type in the schema and project each node with its
25
- * type’s declaration — the multi-shape pipeline. Yields each document tagged
25
+ * type’s declaration – the multi-shape pipeline. Yields each document tagged
26
26
  * with its {@link SearchType} ({@link TypedSearchDocument}), so a downstream
27
27
  * multi-collection writer can route it to that type’s collection; a
28
28
  * single-collection consumer just reads `.document`. Streams one document at a
@@ -82,35 +82,46 @@ function applyField(document, node, field) {
82
82
  }
83
83
  }
84
84
  /**
85
- * Project a text field per locale (untagged literals land in the `und`
86
- * locale). Display shows one label (accents preserved) when the field is
87
- * `output`; sort keys off that same primary value (folded) when `sortable`;
88
- * search folds every value of the locale when `searchable`, so all are
89
- * matchable. Absent locales emit nothing.
85
+ * Project a text field. **Display** (when `output`) preserves *every* language
86
+ * present – one label per language (accents preserved, untagged under `und`),
87
+ * stored `index: false` so extra languages cost nothing – so a value in an
88
+ * undeclared language still renders rather than collapsing to a bare IRI.
89
+ * **Search** (folded, when `searchable`) and **sort** (folded primary, when
90
+ * `sortable`) stay on the declared `locales`, which drive the indexed, stemmed,
91
+ * weighted fanout; a value in an undeclared language is not indexed. Absent
92
+ * languages emit nothing.
90
93
  */
91
94
  function applyText(document, values, field) {
92
- // Empty `locales` is rejected at declaration time (`validateSearchType`);
93
- // here it simply projects nothing.
94
- const locales = field.locales;
95
- const names = physicalFields(field);
96
- locales.forEach((locale, index) => {
97
- const localeValues = values
98
- .filter((value) => value.lang === locale)
99
- .map((value) => value.value);
100
- if (localeValues.length === 0) {
101
- return;
102
- }
103
- const [primary] = localeValues;
104
- if (field.output) {
105
- setString(document, names.display[index], primary);
106
- }
107
- if (field.searchable) {
108
- setString(document, names.search[index], foldedSearchValue(localeValues));
109
- }
110
- if (field.sortable) {
111
- setString(document, names.sort[index], fold(primary));
95
+ if (field.output) {
96
+ // First value of each present language wins; a language absent from
97
+ // `locales` still lands as a display field (kept off the search index).
98
+ const seenLangs = new Set();
99
+ for (const { lang, value } of values) {
100
+ if (!seenLangs.has(lang)) {
101
+ seenLangs.add(lang);
102
+ setString(document, displayFieldName(field, lang), value);
103
+ }
112
104
  }
113
- });
105
+ }
106
+ // Empty `locales` is rejected at declaration time (`validateSearchType`);
107
+ // here it simply indexes nothing.
108
+ if (field.searchable !== undefined || field.sortable === true) {
109
+ const names = physicalFields(field);
110
+ field.locales.forEach((locale, index) => {
111
+ const localeValues = values
112
+ .filter((value) => value.lang === locale)
113
+ .map((value) => value.value);
114
+ if (localeValues.length === 0) {
115
+ return;
116
+ }
117
+ if (field.searchable) {
118
+ setString(document, names.search[index], foldedSearchValue(localeValues));
119
+ }
120
+ if (field.sortable) {
121
+ setString(document, names.sort[index], fold(localeValues[0]));
122
+ }
123
+ });
124
+ }
114
125
  }
115
126
  /** The projection’s definition of a folded free-text search value. */
116
127
  function foldedSearchValue(values) {
@@ -118,7 +129,7 @@ function foldedSearchValue(values) {
118
129
  }
119
130
  /**
120
131
  * Project a faceted multi-value field: dedupe (after the optional transform),
121
- * write the value field, and — when `searchable` — a folded `${name}_search`
132
+ * write the value field, and – when `searchable` – a folded `${name}_search`
122
133
  * array. `keyword` reads literals; `reference` reads IRIs (the caller passes the
123
134
  * already-read raw values).
124
135
  */
@@ -160,10 +171,14 @@ function toLangValue(value) {
160
171
  return undefined;
161
172
  }
162
173
  // Untagged literals (JSON-LD @none) land in the reserved `und` locale.
163
- const lang = isObject(value) && typeof value['@language'] === 'string'
174
+ // Normalise the tag to its BCP-47 shape: `_` is the reserved separator in the
175
+ // physical/display field naming, so a non-conformant `pt_BR` tag becomes
176
+ // `pt-BR`, which round-trips through display and matches a declared locale
177
+ // instead of being silently dropped.
178
+ const rawLang = isObject(value) && typeof value['@language'] === 'string'
164
179
  ? value['@language']
165
180
  : 'und';
166
- return { value: literal, lang };
181
+ return { value: literal, lang: rawLang.replace(/_/g, '-') };
167
182
  }
168
183
  function literalString(value) {
169
184
  if (typeof value === 'string') {
package/dist/schema.d.ts CHANGED
@@ -13,13 +13,13 @@ export type FieldKind = 'text' | 'keyword' | 'integer' | 'number' | 'boolean' |
13
13
  export type FilterOperator = 'in' | 'range' | 'is';
14
14
  /**
15
15
  * The `where` operator a field of this kind accepts (per the ADR filter-semantics
16
- * table), or `undefined` for `text` — which feeds the free-text `query` rather
16
+ * table), or `undefined` for `text` – which feeds the free-text `query` rather
17
17
  * than `where`. The ONE source for the surface’s `where` input type, the
18
18
  * adapter’s filter compiler and declaration validation, so they cannot drift.
19
19
  */
20
20
  export declare function filterOperatorFor(kind: FieldKind): FilterOperator | undefined;
21
21
  /**
22
- * One queryable field — the single declarative source that drives all four
22
+ * One queryable field – the single declarative source that drives all four
23
23
  * consumers (projection, engine collection definition, query semantics, and the
24
24
  * GraphQL surface).
25
25
  *
@@ -32,8 +32,8 @@ export declare function filterOperatorFor(kind: FieldKind): FilterOperator | und
32
32
  * Capability flags (`searchable`/`filterable`/`facetable`/`sortable`/`output`)
33
33
  * are independent opt-ins: a field exposes exactly the roles it declares. A
34
34
  * field with a {@link SearchFieldBase.derive `derive`} function instead of a
35
- * `path` is a **derived field** — computed from the framed node rather than
36
- * projected from the IR — yet it still carries full query/schema/output
35
+ * `path` is a **derived field** – computed from the framed node rather than
36
+ * projected from the IR – yet it still carries full query/schema/output
37
37
  * behavior (e.g. `status`, the compatibility booleans).
38
38
  *
39
39
  * The physical field names a declaration fans out to (per-locale search/sort
@@ -72,7 +72,7 @@ export interface SearchFieldBase {
72
72
  /** Publicly selectable in `orderBy`; localized text also emits a folded sort key. */
73
73
  readonly sortable?: boolean;
74
74
  /**
75
- * Compute this field’s value instead of projecting it from a `path` — a
75
+ * Compute this field’s value instead of projecting it from a `path` – a
76
76
  * status token, a compatibility boolean, a count over the framed node.
77
77
  * Mutually exclusive with `path`. Runs in declaration order during
78
78
  * projection, receiving the framed node and the document as populated so
@@ -95,7 +95,7 @@ export interface Searchable {
95
95
  * locale into display/search/sort companions. `locales` lists the language
96
96
  * tags to emit; the reserved locale **`und`** (JSON-LD `@none`, RDF `und`)
97
97
  * buckets untagged literals, so a monolingual or untagged corpus declares
98
- * `locales: ['und']` and mixed data `['nl', 'und']` — one mechanism, and
98
+ * `locales: ['und']` and mixed data `['nl', 'und']` – one mechanism, and
99
99
  * adding a language later is additive (the API output shape never changes).
100
100
  * Declaring a real language is RECOMMENDED where the data has one: it drives
101
101
  * the engine’s per-locale stemming; `und` is folded but unstemmed (unless the
@@ -139,7 +139,7 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
139
139
  * optional for a facet- or filter-only reference. */
140
140
  readonly ref?: {
141
141
  /** Logical API type name of the referenced entity (PascalCase, e.g.
142
- * `Organization`) — names the reference’s type in the API surfaces, the
142
+ * `Organization`) – names the reference’s type in the API surfaces, the
143
143
  * way {@link SearchType.name} names a root type; fields sharing it share
144
144
  * one emitted type. A name, not a key: it need not correspond to any
145
145
  * indexed root type (and until cross-collection references exist, it must
@@ -156,7 +156,7 @@ export interface ReferenceField extends SearchFieldBase, Searchable {
156
156
  /**
157
157
  * Range-facet bins for a numeric (`integer`/`number`/`date`) facetable field.
158
158
  * When set, the field facets into these fixed half-open `[min, max)` ranges (a
159
- * histogram) rather than one bucket per distinct value — the per-bucket counts
159
+ * histogram) rather than one bucket per distinct value – the per-bucket counts
160
160
  * a UI slider needs. Bins are query-time only (no index impact) and
161
161
  * engine-neutral: the Typesense adapter emits a `facet_by` range, an
162
162
  * OpenSearch adapter a `range` aggregation. See {@link FacetRange}.
@@ -167,7 +167,7 @@ export interface RangeFacetable {
167
167
  /**
168
168
  * A numeric value: range-filtered, range- or value-faceted, sortable.
169
169
  * `integer` is a whole number, `number` a float, `date` a point in time
170
- * (ISO 8601 at the edges, Unix seconds in the index) — identical capabilities,
170
+ * (ISO 8601 at the edges, Unix seconds in the index) – identical capabilities,
171
171
  * so one interface serves all three kinds; `field.kind` still narrows.
172
172
  */
173
173
  export interface NumericField extends SearchFieldBase, RangeFacetable {
@@ -200,7 +200,7 @@ export interface FacetRange {
200
200
  * not a requirement.
201
201
  */
202
202
  export interface SearchType {
203
- /** Logical API name (PascalCase, e.g. `Dataset`) — names the type in the API
203
+ /** Logical API name (PascalCase, e.g. `Dataset`) – names the type in the API
204
204
  * surfaces (GraphQL type names, a REST path), the way each field’s
205
205
  * {@link SearchField.name} names that field. Deliberately declared rather
206
206
  * than derived from the `class` IRI, so re-modelling the vocabulary cannot
@@ -214,7 +214,7 @@ export interface SearchType {
214
214
  /**
215
215
  * Declare a {@link SearchType}, capturing it as a literal: the `const` type
216
216
  * parameter preserves the field names and capability flags that the type-level
217
- * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type —
217
+ * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type –
218
218
  * with none of the widening a plain `: SearchType` annotation causes and
219
219
  * without having to remember `as const satisfies SearchType`. Identity at
220
220
  * runtime.
@@ -228,7 +228,7 @@ export declare function defineSearchType<const Type extends SearchType>(searchTy
228
228
  * `: SearchSchema` annotation widens gracefully to `SearchType`.
229
229
  */
230
230
  /** Brand for {@link SearchSchema}: type-only, no runtime existence. Makes the
231
- * schema NOMINAL — a hand-built `Map` is not assignable, so `searchSchema()`
231
+ * schema NOMINAL – a hand-built `Map` is not assignable, so `searchSchema()`
232
232
  * (which validates) is the only way to obtain one and downstream consumers
233
233
  * need no defensive re-validation. */
234
234
  export declare const validSearchSchema: unique symbol;
@@ -238,8 +238,8 @@ export interface SearchSchema<Types extends readonly SearchType[] = readonly Sea
238
238
  /**
239
239
  * Build a {@link SearchSchema} from root-type declarations, keyed by `class`.
240
240
  *
241
- * Every declaration is validated ({@link assertValidSearchType}) — the
242
- * declaration-time counterpart of the port’s `assertValidQuery` — and the
241
+ * Every declaration is validated ({@link assertValidSearchType}) – the
242
+ * declaration-time counterpart of the port’s `assertValidQuery` – and the
243
243
  * schema-wide invariants are enforced: no two types may share a `class` IRI
244
244
  * (they would silently overwrite each other in the map) or a `name` (names
245
245
  * key the API surfaces). Throws on the first invalid declaration, so a bad
@@ -263,21 +263,25 @@ export declare function labelFieldOf(searchType: SearchType): TextField | undefi
263
263
  */
264
264
  export interface SearchTypeIssue {
265
265
  readonly field: string;
266
- readonly reason: 'duplicate-field-name' | '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';
266
+ readonly reason: 'duplicate-field-name' | 'invalid-field-name' | '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';
267
267
  }
268
268
  /**
269
- * Structurally validate one {@link SearchType} declaration — the
269
+ * Structurally validate one {@link SearchType} declaration – the
270
270
  * declaration-time counterpart of `validateQuery`. Rules:
271
271
  *
272
272
  * - field names are unique (a duplicate would silently shadow in every
273
- * consumer, each picking a different winner);
273
+ * consumer, each picking a different winner) and a metacharacter-free
274
+ * identifier (the name is interpolated into physical field names and the
275
+ * display RE2 pattern);
276
+ * - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
277
+ * reserved name↔locale separator);
274
278
  * - a `reference` field that is `output` declares `ref` (the API surfaces
275
279
  * need the reference type name); `ref` on any other kind is meaningless;
276
280
  * - a `text` field declares at least one locale (`und` = untagged; projection and
277
- * result reconstruction have no representation for unlocalized text — use
281
+ * result reconstruction have no representation for unlocalized text – use
278
282
  * `keyword` for untagged strings); `locales` on any other kind is
279
283
  * meaningless;
280
- * - a kind without a `where` operator (`text` — it feeds the free-text query)
284
+ * - a kind without a `where` operator (`text` – it feeds the free-text query)
281
285
  * is neither `filterable` nor `facetable`;
282
286
  * - `facetRanges` only on the `range`-operator kinds (`integer`/`number`/`date`);
283
287
  * - `searchable` only on `text`/`keyword`/`reference` (projection emits no
@@ -292,7 +296,7 @@ export interface SearchTypeIssue {
292
296
  */
293
297
  export declare function validateSearchType(searchType: SearchType): readonly SearchTypeIssue[];
294
298
  /**
295
- * Throw when `searchType` is not a member of `schema` — the port membership
299
+ * Throw when `searchType` is not a member of `schema` – the port membership
296
300
  * guard every engine adapter applies before searching, so a query can never
297
301
  * meet an index the deployment did not declare. Identity-based: the exact
298
302
  * declaration object must be in the schema, not a lookalike.
@@ -308,8 +312,6 @@ export declare function assertValidSearchType(searchType: SearchType): void;
308
312
  * query compiler (reads them) cannot disagree.
309
313
  */
310
314
  export interface PhysicalFields {
311
- /** Per-locale output labels `${name}_${locale}` (localized text, `output`). */
312
- readonly display: readonly string[];
313
315
  /** Folded match fields: `${name}_search_${locale}` per locale (localized) or a
314
316
  * single `${name}_search` (non-localized), when `searchable`. */
315
317
  readonly search: readonly string[];
@@ -318,7 +320,44 @@ export interface PhysicalFields {
318
320
  readonly sort: readonly string[];
319
321
  }
320
322
  /**
321
- * Full-text searchable fields, highest `query_by` weight first — the order the
323
+ * The display fields of a localized `text` field are pattern-based, not
324
+ * enumerated per declared locale: projection stores `${name}_${lang}` for
325
+ * **every** language present in the data (not only those in `locales`), so a
326
+ * label in an undeclared language or an untagged one still renders rather than
327
+ * collapsing to a bare IRI. `locales` governs only the indexed search/sort
328
+ * fanout ({@link PhysicalFields}); display costs nothing per language (stored
329
+ * `index: false`), so it preserves them all. A deployment that wants fewer
330
+ * display languages restricts them upstream (e.g. in its CONSTRUCT).
331
+ *
332
+ * A language subtag never contains `_`, but the `search_`/`sort_` infixes do,
333
+ * so `${name}_${lang}` with `lang` matching `[^_]+` is unambiguously a display
334
+ * field: `label_nl`, `label_fr`, `label_zh-hant`, `label_und` are display;
335
+ * `label_search_nl` and `label_sort_nl` are not. This trio – the name a value
336
+ * is written under ({@link displayFieldName}), the collection pattern that
337
+ * accepts them all ({@link displayFieldPattern}), and the reader that recovers a
338
+ * key’s language ({@link displayLangOf}) – is the single home of that
339
+ * convention, so projection, collection-definition and result reconstruction
340
+ * cannot disagree.
341
+ */
342
+ export declare function displayFieldName(field: TextField, lang: string): string;
343
+ /**
344
+ * The RE2 pattern a collection declares to store every present language’s
345
+ * display value un-indexed, or `undefined` when the field is not `output` (no
346
+ * display at all). Matches `${name}_${lang}` for any underscore-free `lang`, so
347
+ * it never collides with the field’s `${name}_search_${locale}` /
348
+ * `${name}_sort_${locale}` companions.
349
+ */
350
+ export declare function displayFieldPattern(field: TextField): string | undefined;
351
+ /**
352
+ * The language a stored document key carries for `field`’s display, or
353
+ * `undefined` when the key is not one of `field`’s display fields – the inverse
354
+ * of {@link displayFieldName}. A key qualifies when it is `${name}_` followed by
355
+ * an underscore-free remainder, so the `${name}_search_…`/`${name}_sort_…`
356
+ * companions (and unrelated fields) are rejected.
357
+ */
358
+ export declare function displayLangOf(field: TextField, key: string): string | undefined;
359
+ /**
360
+ * Full-text searchable fields, highest `query_by` weight first – the order the
322
361
  * engine adapter weights `query_by` in. A field is searchable iff it carries a
323
362
  * `searchable` weight.
324
363
  */
@@ -359,6 +398,11 @@ export declare function isRangeFacet(field: SearchField): field is NumericField
359
398
  export declare function isoToUnixSeconds(iso: string): number | undefined;
360
399
  /** The inverse of {@link isoToUnixSeconds}: stored Unix seconds → ISO 8601. */
361
400
  export declare function unixSecondsToIso(seconds: number): string;
362
- /** Derive the physical engine field names a declaration produces. */
401
+ /**
402
+ * Derive the **indexed** physical engine field names a declaration produces:
403
+ * the per-locale `search`/`sort` fanout. A localized `text` field’s display
404
+ * fields are pattern-based and not enumerated here – see {@link displayFieldName}
405
+ * and its siblings.
406
+ */
363
407
  export declare function physicalFields(field: SearchField): PhysicalFields;
364
408
  //# sourceMappingURL=schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,SAAS,GACT,SAAS,GACT,QAAQ,GACR,SAAS,GACT,MAAM,GACN,WAAW,CAAC;AAEhB;oEACoE;AACpE,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,OAAO,GAAG,IAAI,CAAC;AAcnD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,GAAG,cAAc,GAAG,SAAS,CAE7E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,MAAM,WAAW,GACnB,SAAS,GACT,YAAY,GACZ,cAAc,GACd,YAAY,GACZ,YAAY,CAAC;AAEjB,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC9B;wCACoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;+EAC2E;IAC3E,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,oBAAoB;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB;;yCAEqC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,gEAAgE;IAChE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,yBAAyB;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,iCAAiC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,qFAAqF;IACrF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;OASG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,cAAc,KAAK,OAAO,CAAC;CAC3E;AAED;kEACkE;AAClE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,SAAU,SAAQ,eAAe,EAAE,UAAU;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,CAAC;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,KAAK,CAAC;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC;CAC9B;AAED;gCACgC;AAChC,MAAM,WAAW,YAAa,SAAQ,eAAe,EAAE,UAAU;IAC/D,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC;CAC9B;AAED,gFAAgF;AAChF,MAAM,WAAW,cAAe,SAAQ,eAAe,EAAE,UAAU;IACjE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,uCAAuC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC;IAC7B;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;0DAEsD;IACtD,QAAQ,CAAC,GAAG,CAAC,EAAE;QACb;;;;;qCAK6B;QAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B;;;;6EAIqE;QACrE,QAAQ,CAAC,QAAQ,EAAE,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;KACtD,CAAC;CACH;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAC9C;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAa,SAAQ,eAAe,EAAE,cAAc;IACnE,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC7C,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,CAAC;CAC7B;AAED,0DAA0D;AAC1D,MAAM,WAAW,YAAa,SAAQ,eAAe;IACnD,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,CAAC;IAC5B,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAC;CAC9B;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB;;;;+CAI2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;2DACuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,CAAC,IAAI,SAAS,UAAU,EAC5D,UAAU,EAAE,IAAI,GACf,IAAI,CAEN;AAED;;;;;;GAMG;AACH;;;uCAGuC;AACvC,MAAM,CAAC,OAAO,CAAC,MAAM,iBAAiB,EAAE,OAAO,MAAM,CAAC;AAEtD,MAAM,WAAW,YAAY,CAC3B,KAAK,SAAS,SAAS,UAAU,EAAE,GAAG,SAAS,UAAU,EAAE,CAC3D,SAAQ,WAAW,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC1C,QAAQ,CAAC,CAAC,iBAAiB,CAAC,EAAE,IAAI,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EACpE,GAAG,KAAK,EAAE,KAAK,GACd,YAAY,CAAC,KAAK,CAAC,CAuBrB;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,GAAG,SAAS,CAQ1E;AAsCD;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EACX,sBAAsB,GACtB,oBAAoB,GACpB,gBAAgB,GAChB,aAAa,GACb,iBAAiB,GACjB,uBAAuB,GACvB,qBAAqB,GACrB,0BAA0B,GAC1B,wBAAwB,GACxB,uBAAuB,GACvB,kBAAkB,GAClB,qBAAqB,GACrB,oBAAoB,CAAC;CAC1B;AA0BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,UAAU,GACrB,SAAS,eAAe,EAAE,CA+E5B;AAWD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,UAAU,GACrB,IAAI,CAUN;AAED;+EAC+E;AAC/E,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,UAAU,GAAG,IAAI,CAQlE;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B;sEACkE;IAClE,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC;4EACwE;IACxE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,SAAS,EAChB,GAAG,EAAE,MAAM,GACV,MAAM,GAAG,SAAS,CAOpB;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,UAAU,GACrB,SAAS,CAAC,WAAW,GAAG;IACzB,QAAQ,CAAC,UAAU,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD,CAAC,EAAE,CAOH;AAED,8DAA8D;AAC9D,wBAAgB,eAAe,CAC7B,UAAU,EAAE,UAAU,GACrB,SAAS,WAAW,EAAE,CAExB;AAED,sDAAsD;AACtD,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,UAAU,GACrB,SAAS,WAAW,EAAE,CAExB;AAED,qEAAqE;AACrE,wBAAgB,cAAc,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,WAAW,EAAE,CAE7E;AAED,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,WAAW,EAAE,CAE3E;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAC7B,UAAU,EAAE,UAAU,GACrB,SAAS,cAAc,EAAE,CAE3B;AAED,2CAA2C;AAC3C,wBAAgB,UAAU,CACxB,UAAU,EAAE,UAAU,EACtB,IAAI,EAAE,MAAM,GACX,WAAW,GAAG,SAAS,CAEzB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,WAAW,GACjB,KAAK,IAAI,YAAY,GAAG;IAAE,QAAQ,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAEzE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGhE;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,cAAc,CAgBjE"}
package/dist/schema.js CHANGED
@@ -9,7 +9,7 @@ const OPERATOR_BY_KIND = {
9
9
  };
10
10
  /**
11
11
  * The `where` operator a field of this kind accepts (per the ADR filter-semantics
12
- * table), or `undefined` for `text` — which feeds the free-text `query` rather
12
+ * table), or `undefined` for `text` – which feeds the free-text `query` rather
13
13
  * than `where`. The ONE source for the surface’s `where` input type, the
14
14
  * adapter’s filter compiler and declaration validation, so they cannot drift.
15
15
  */
@@ -19,7 +19,7 @@ export function filterOperatorFor(kind) {
19
19
  /**
20
20
  * Declare a {@link SearchType}, capturing it as a literal: the `const` type
21
21
  * parameter preserves the field names and capability flags that the type-level
22
- * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type —
22
+ * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type –
23
23
  * with none of the widening a plain `: SearchType` annotation causes and
24
24
  * without having to remember `as const satisfies SearchType`. Identity at
25
25
  * runtime.
@@ -30,8 +30,8 @@ export function defineSearchType(searchType) {
30
30
  /**
31
31
  * Build a {@link SearchSchema} from root-type declarations, keyed by `class`.
32
32
  *
33
- * Every declaration is validated ({@link assertValidSearchType}) — the
34
- * declaration-time counterpart of the port’s `assertValidQuery` — and the
33
+ * Every declaration is validated ({@link assertValidSearchType}) – the
34
+ * declaration-time counterpart of the port’s `assertValidQuery` – and the
35
35
  * schema-wide invariants are enforced: no two types may share a `class` IRI
36
36
  * (they would silently overwrite each other in the map) or a `name` (names
37
37
  * key the API surfaces). Throws on the first invalid declaration, so a bad
@@ -104,18 +104,38 @@ const SEARCHABLE_KINDS = ['text', 'keyword', 'reference'];
104
104
  /** Kinds whose projection applies the {@link KeywordField.transform}. */
105
105
  const TRANSFORMABLE_KINDS = ['keyword', 'reference'];
106
106
  /**
107
- * Structurally validate one {@link SearchType} declaration — the
107
+ * A safe logical field name: a GraphQL-style identifier. The name is
108
+ * interpolated raw into physical field names AND, for a display text field,
109
+ * into the RE2 collection pattern `${name}_[^_]+` ({@link displayFieldPattern}),
110
+ * so it must contain no regex metacharacter – this charset (letters, digits,
111
+ * `_`) guarantees that, and is exactly what a GraphQL field name allows anyway.
112
+ */
113
+ const FIELD_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
114
+ /**
115
+ * A safe declared locale: BCP-47-shaped (letters/digits, `-` between subtags),
116
+ * never containing `_`. The `_` is the reserved separator between a text field’s
117
+ * name and its language subtag, so a locale carrying one would collide with the
118
+ * `${name}_search_${locale}` / display naming; incoming data tags are normalised
119
+ * to this shape at projection time.
120
+ */
121
+ const LOCALE_PATTERN = /^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/;
122
+ /**
123
+ * Structurally validate one {@link SearchType} declaration – the
108
124
  * declaration-time counterpart of `validateQuery`. Rules:
109
125
  *
110
126
  * - field names are unique (a duplicate would silently shadow in every
111
- * consumer, each picking a different winner);
127
+ * consumer, each picking a different winner) and a metacharacter-free
128
+ * identifier (the name is interpolated into physical field names and the
129
+ * display RE2 pattern);
130
+ * - a `text` field’s declared locales are BCP-47-shaped (no `_`, which is the
131
+ * reserved name↔locale separator);
112
132
  * - a `reference` field that is `output` declares `ref` (the API surfaces
113
133
  * need the reference type name); `ref` on any other kind is meaningless;
114
134
  * - a `text` field declares at least one locale (`und` = untagged; projection and
115
- * result reconstruction have no representation for unlocalized text — use
135
+ * result reconstruction have no representation for unlocalized text – use
116
136
  * `keyword` for untagged strings); `locales` on any other kind is
117
137
  * meaningless;
118
- * - a kind without a `where` operator (`text` — it feeds the free-text query)
138
+ * - a kind without a `where` operator (`text` – it feeds the free-text query)
119
139
  * is neither `filterable` nor `facetable`;
120
140
  * - `facetRanges` only on the `range`-operator kinds (`integer`/`number`/`date`);
121
141
  * - `searchable` only on `text`/`keyword`/`reference` (projection emits no
@@ -141,6 +161,11 @@ export function validateSearchType(searchType) {
141
161
  issue('duplicate-field-name');
142
162
  }
143
163
  seen.add(field.name);
164
+ // The name is interpolated into physical field names and the display RE2
165
+ // pattern, so it must be a metacharacter-free identifier.
166
+ if (!FIELD_NAME_PATTERN.test(field.name)) {
167
+ issue('invalid-field-name');
168
+ }
144
169
  if (field.kind === 'reference') {
145
170
  if (field.output === true && field.ref === undefined) {
146
171
  issue('missing-ref');
@@ -153,6 +178,12 @@ export function validateSearchType(searchType) {
153
178
  if ((field.locales ?? []).length === 0) {
154
179
  issue('text-requires-locales');
155
180
  }
181
+ // A locale carrying `_` would collide with the name/locale separator in
182
+ // the physical and display field naming, so declared locales are
183
+ // BCP-47-shaped (data tags are normalised to match at projection time).
184
+ if ((field.locales ?? []).some((locale) => !LOCALE_PATTERN.test(locale))) {
185
+ issue('invalid-locale');
186
+ }
156
187
  }
157
188
  else if (field.locales !== undefined) {
158
189
  issue('locales-not-allowed');
@@ -187,7 +218,7 @@ export function validateSearchType(searchType) {
187
218
  return issues;
188
219
  }
189
220
  /**
190
- * Throw when `searchType` is not a member of `schema` — the port membership
221
+ * Throw when `searchType` is not a member of `schema` – the port membership
191
222
  * guard every engine adapter applies before searching, so a query can never
192
223
  * meet an index the deployment did not declare. Identity-based: the exact
193
224
  * declaration object must be in the schema, not a lookalike.
@@ -213,7 +244,55 @@ export function assertValidSearchType(searchType) {
213
244
  }
214
245
  }
215
246
  /**
216
- * Full-text searchable fields, highest `query_by` weight first — the order the
247
+ * The display fields of a localized `text` field are pattern-based, not
248
+ * enumerated per declared locale: projection stores `${name}_${lang}` for
249
+ * **every** language present in the data (not only those in `locales`), so a
250
+ * label in an undeclared language or an untagged one still renders rather than
251
+ * collapsing to a bare IRI. `locales` governs only the indexed search/sort
252
+ * fanout ({@link PhysicalFields}); display costs nothing per language (stored
253
+ * `index: false`), so it preserves them all. A deployment that wants fewer
254
+ * display languages restricts them upstream (e.g. in its CONSTRUCT).
255
+ *
256
+ * A language subtag never contains `_`, but the `search_`/`sort_` infixes do,
257
+ * so `${name}_${lang}` with `lang` matching `[^_]+` is unambiguously a display
258
+ * field: `label_nl`, `label_fr`, `label_zh-hant`, `label_und` are display;
259
+ * `label_search_nl` and `label_sort_nl` are not. This trio – the name a value
260
+ * is written under ({@link displayFieldName}), the collection pattern that
261
+ * accepts them all ({@link displayFieldPattern}), and the reader that recovers a
262
+ * key’s language ({@link displayLangOf}) – is the single home of that
263
+ * convention, so projection, collection-definition and result reconstruction
264
+ * cannot disagree.
265
+ */
266
+ export function displayFieldName(field, lang) {
267
+ return `${field.name}_${lang}`;
268
+ }
269
+ /**
270
+ * The RE2 pattern a collection declares to store every present language’s
271
+ * display value un-indexed, or `undefined` when the field is not `output` (no
272
+ * display at all). Matches `${name}_${lang}` for any underscore-free `lang`, so
273
+ * it never collides with the field’s `${name}_search_${locale}` /
274
+ * `${name}_sort_${locale}` companions.
275
+ */
276
+ export function displayFieldPattern(field) {
277
+ return field.output ? `${field.name}_[^_]+` : undefined;
278
+ }
279
+ /**
280
+ * The language a stored document key carries for `field`’s display, or
281
+ * `undefined` when the key is not one of `field`’s display fields – the inverse
282
+ * of {@link displayFieldName}. A key qualifies when it is `${name}_` followed by
283
+ * an underscore-free remainder, so the `${name}_search_…`/`${name}_sort_…`
284
+ * companions (and unrelated fields) are rejected.
285
+ */
286
+ export function displayLangOf(field, key) {
287
+ const prefix = `${field.name}_`;
288
+ if (!key.startsWith(prefix)) {
289
+ return undefined;
290
+ }
291
+ const lang = key.slice(prefix.length);
292
+ return lang.length > 0 && !lang.includes('_') ? lang : undefined;
293
+ }
294
+ /**
295
+ * Full-text searchable fields, highest `query_by` weight first – the order the
217
296
  * engine adapter weights `query_by` in. A field is searchable iff it carries a
218
297
  * `searchable` weight.
219
298
  */
@@ -271,14 +350,16 @@ export function isoToUnixSeconds(iso) {
271
350
  export function unixSecondsToIso(seconds) {
272
351
  return new Date(seconds * 1000).toISOString();
273
352
  }
274
- /** Derive the physical engine field names a declaration produces. */
353
+ /**
354
+ * Derive the **indexed** physical engine field names a declaration produces:
355
+ * the per-locale `search`/`sort` fanout. A localized `text` field’s display
356
+ * fields are pattern-based and not enumerated here – see {@link displayFieldName}
357
+ * and its siblings.
358
+ */
275
359
  export function physicalFields(field) {
276
360
  if (field.kind === 'text') {
277
361
  const locales = field.locales;
278
362
  return {
279
- display: field.output
280
- ? locales.map((locale) => `${field.name}_${locale}`)
281
- : [],
282
363
  search: field.searchable
283
364
  ? locales.map((locale) => `${field.name}_search_${locale}`)
284
365
  : [],
@@ -288,7 +369,6 @@ export function physicalFields(field) {
288
369
  };
289
370
  }
290
371
  return {
291
- display: [],
292
372
  search: field.searchable !== undefined ? [`${field.name}_search`] : [],
293
373
  sort: [],
294
374
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",
@@ -40,6 +40,7 @@
40
40
  "@lde/text-normalization": "^0.1.1",
41
41
  "@rdfjs/types": "^2.0.1",
42
42
  "@tpluscode/rdf-ns-builders": "^5.0.0",
43
+ "inflection": "^3.0.2",
43
44
  "jsonld": "^9.0.0",
44
45
  "tslib": "^2.3.0"
45
46
  },