@lde/search 0.1.1 → 0.2.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/dist/project.d.ts CHANGED
@@ -1,98 +1,26 @@
1
1
  import type { Quad } from '@rdfjs/types';
2
2
  import { type FramedNode } from './frame-by-type.js';
3
+ import { type SearchSchema, type SearchType } from './schema.js';
3
4
  /** A flat search document. `id` is the engine document key. */
4
5
  export type SearchDocument = {
5
6
  id: string;
6
7
  } & Record<string, unknown>;
7
- /**
8
- * How one framed-IR property projects into search fields. The vocabulary mirrors
9
- * SHACL so a generator can later emit it from shapes + search annotations:
10
- * `path` is `sh:path`, and the kind is derivable from `sh:datatype`/`sh:nodeKind`
11
- * /`sh:maxCount` plus the search annotations.
12
- */
13
- export type FieldKind = LangTextKind | FacetKind | NumberKind | DateKind;
14
- /**
15
- * Language-tagged text, projected per locale. `locales` is the single source of
16
- * truth for which languages this field emits; `display`, `search` and `sort` are
17
- * three independent opt-in families that each fan out over it:
18
- * - `display` → `${name}_${locale}` display label, accents preserved;
19
- * - `search` → `${name}_search_${locale}` folded match field (one per locale so
20
- * the engine can tokenize/stem each language and the query can rank the user’s
21
- * locale higher);
22
- * - `sort` → `${name}_sort_${locale}` folded sort key (one per locale so a
23
- * locale-switching UI sorts on the active language).
24
- *
25
- * All three default off — a field emits exactly the families it opts into (e.g.
26
- * `search` alone is a search-only field, shown via a separate label). Only listed
27
- * locales are projected: a value whose language tag is not in `locales` (and is
28
- * not mapped in by `untaggedLanguage`) is not indexed at all.
29
- */
30
- export interface LangTextKind {
31
- readonly type: 'langText';
32
- /** The languages to project; drives whichever of the families are enabled. */
33
- readonly locales: readonly string[];
34
- /** Emit the per-locale display labels `${name}_${locale}` (accents preserved). */
35
- readonly display?: boolean;
36
- /** Emit a folded `${name}_search_${locale}` per locale (matchable). */
37
- readonly search?: boolean;
38
- /** Emit a folded `${name}_sort_${locale}` per locale (sortable). */
39
- readonly sort?: boolean;
40
- }
41
- /** A faceted multi-value field, optionally also folded for search. */
42
- export interface FacetKind {
43
- readonly type: 'facet';
44
- /** Read IRI references (`@id`) rather than literal values. */
45
- readonly iri?: boolean;
46
- /** Also emit a folded `${name}_search` array. */
47
- readonly search?: boolean;
48
- /** Transform each value before faceting (e.g. strip a media-type prefix). */
49
- readonly transform?: (value: string) => string;
50
- }
51
- /** A numeric scalar. */
52
- export interface NumberKind {
53
- readonly type: 'number';
54
- }
55
- /** An ISO date-time, parsed into Unix seconds. */
56
- export interface DateKind {
57
- readonly type: 'date';
58
- }
59
- /**
60
- * One field of a projection: an output `name`, the framed-IR predicate `path` to
61
- * read (the SHACL `sh:path`), and the kind-specific config discriminated by
62
- * `type`.
63
- */
64
- export type FieldSpec = {
65
- /** Output field base name; per-kind suffixes are appended. */
66
- readonly name: string;
67
- /** Framed-IR predicate IRI to read (the SHACL `sh:path`). */
68
- readonly path: string;
69
- } & FieldKind;
70
- /** A computed field that is not a direct projection of a single path
71
- * (e.g. a status rank, or a group derived from a code table). */
72
- export type Derivation = (document: SearchDocument, node: FramedNode) => void;
73
- /**
74
- * One root type’s complete projection — the runtime form of a single SHACL
75
- * NodeShape: `type` is its `sh:targetClass` (and the framed node’s `@type`),
76
- * `fields` are its property shapes, and `derivations` are its `sh:rule`-shaped
77
- * computed fields. A generator emits one of these per NodeShape.
78
- */
79
- export interface Projection {
80
- readonly type: string;
81
- readonly fields: readonly FieldSpec[];
82
- readonly derivations?: readonly Derivation[];
83
- }
84
8
  /**
85
9
  * Project one framed JSON-LD node into a flat search document: apply each field
86
- * spec, then run the derivations (which may read fields the specs already set).
10
+ * of the type in declaration order. A field with a `derive` function computes
11
+ * its value from the node and the document as populated so far (so a derived
12
+ * field may read fields declared before it). The physical field names a field
13
+ * fans out to come from {@link physicalFields}, the single source shared with
14
+ * the engine collection schema and the query compiler.
87
15
  */
88
- export declare function projectDocument(node: FramedNode, projection: Projection): SearchDocument;
16
+ export declare function projectDocument(node: FramedNode, searchType: SearchType): SearchDocument;
89
17
  /**
90
- * Frame `quads` for every projection’s root type and project each node with its
91
- * type’s projection — the multi-shape pipeline. Streams one document at a time
92
- * so memory stays flat. The IR maps to a projection by type, so adding a shape
93
- * is adding a `Projection` (no engine change).
18
+ * Frame `quads` for every root type in the schema and project each node with its
19
+ * type’s declaration — the multi-shape pipeline. Streams one document at a time
20
+ * so memory stays flat. The IR maps to a declaration by type, so adding a shape
21
+ * is adding a `SearchType` to the schema (no engine change).
94
22
  */
95
- export declare function projectGraph(quads: readonly Quad[], projections: readonly Projection[]): AsyncIterable<SearchDocument>;
23
+ export declare function projectGraph(quads: readonly Quad[], schema: SearchSchema): AsyncIterable<SearchDocument>;
96
24
  export declare function literalsOf(node: FramedNode, path: string): string[];
97
25
  export declare function firstLiteralOf(node: FramedNode, path: string): string | undefined;
98
26
  export declare function irisOf(node: FramedNode, path: string): string[];
@@ -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,EAAe,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAElE,+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,MAAM,SAAS,GAAG,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEzE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,sEAAsE;AACtE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,iDAAiD;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAChD;AAED,wBAAwB;AACxB,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;CACzB;AAED,kDAAkD;AAClD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,GAAG,SAAS,CAAC;AAEd;kEACkE;AAClE,MAAM,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;AAE9E;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAC;IACtC,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,GACrB,cAAc,CAehB;AAED;;;;;GAKG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,WAAW,EAAE,SAAS,UAAU,EAAE,GACjC,aAAa,CAAC,cAAc,CAAC,CAS/B;AA+FD,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,EAAe,KAAK,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAClE,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;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,UAAU,GACrB,cAAc,CAYhB;AAED;;;;;GAKG;AACH,wBAAuB,YAAY,CACjC,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,MAAM,EAAE,YAAY,GACnB,aAAa,CAAC,cAAc,CAAC,CAM/B;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"}
package/dist/project.js CHANGED
@@ -1,81 +1,122 @@
1
1
  import { fold } from '@lde/text-normalization';
2
2
  import { frameByType } from './frame-by-type.js';
3
+ import { isoToUnixSeconds, physicalFields, } from './schema.js';
3
4
  /**
4
5
  * Project one framed JSON-LD node into a flat search document: apply each field
5
- * spec, then run the derivations (which may read fields the specs already set).
6
+ * of the type in declaration order. A field with a `derive` function computes
7
+ * its value from the node and the document as populated so far (so a derived
8
+ * field may read fields declared before it). The physical field names a field
9
+ * fans out to come from {@link physicalFields}, the single source shared with
10
+ * the engine collection schema and the query compiler.
6
11
  */
7
- export function projectDocument(node, projection) {
12
+ export function projectDocument(node, searchType) {
8
13
  const id = node['@id'];
9
14
  if (typeof id !== 'string') {
10
- throw new Error(`Cannot project a ${projection.type} node without an @id: every search document needs a stable key, and an empty one would collide with other keyless nodes.`);
15
+ throw new Error(`Cannot project a ${searchType.type} node without an @id: every search document needs a stable key, and an empty one would collide with other keyless nodes.`);
11
16
  }
12
17
  const document = { id };
13
- for (const field of projection.fields) {
18
+ for (const field of searchType.fields) {
14
19
  applyField(document, node, field);
15
20
  }
16
- for (const derive of projection.derivations ?? []) {
17
- derive(document, node);
18
- }
19
21
  return document;
20
22
  }
21
23
  /**
22
- * Frame `quads` for every projection’s root type and project each node with its
23
- * type’s projection — the multi-shape pipeline. Streams one document at a time
24
- * so memory stays flat. The IR maps to a projection by type, so adding a shape
25
- * is adding a `Projection` (no engine change).
24
+ * Frame `quads` for every root type in the schema and project each node with its
25
+ * type’s declaration — the multi-shape pipeline. Streams one document at a time
26
+ * so memory stays flat. The IR maps to a declaration by type, so adding a shape
27
+ * is adding a `SearchType` to the schema (no engine change).
26
28
  */
27
- export async function* projectGraph(quads, projections) {
28
- const byType = new Map(projections.map((projection) => [projection.type, projection]));
29
- for (const projection of byType.values()) {
30
- for await (const node of frameByType(quads, projection.type)) {
31
- yield projectDocument(node, projection);
29
+ export async function* projectGraph(quads, schema) {
30
+ for (const searchType of schema.values()) {
31
+ for await (const node of frameByType(quads, searchType.type)) {
32
+ yield projectDocument(node, searchType);
32
33
  }
33
34
  }
34
35
  }
35
36
  function applyField(document, node, field) {
36
- switch (field.type) {
37
- case 'langText':
38
- return applyLangText(document, langValuesOf(node, field.path), field);
39
- case 'facet':
40
- return applyFacet(document, node, field);
37
+ if (field.derive !== undefined) {
38
+ const value = field.derive(node, document);
39
+ if (value !== undefined) {
40
+ document[field.name] = value;
41
+ }
42
+ return;
43
+ }
44
+ const path = field.path;
45
+ if (path === undefined) {
46
+ // Neither path nor derive: populated outside the projection, if at all.
47
+ return;
48
+ }
49
+ switch (field.kind) {
50
+ case 'text':
51
+ return applyText(document, langValuesOf(node, path), field);
52
+ case 'keyword':
53
+ return applyFacet(document, literalsOf(node, path), field);
54
+ case 'reference':
55
+ return applyFacet(document, irisOf(node, path), field);
56
+ case 'integer':
57
+ return setNumber(document, field.name, toInteger(firstLiteralOf(node, path)));
41
58
  case 'number':
42
- return setNumber(document, field.name, toInteger(firstLiteralOf(node, field.path)));
43
- case 'date':
44
- return setNumber(document, field.name, isoToUnix(firstLiteralOf(node, field.path)));
59
+ return setNumber(document, field.name, toNumber(firstLiteralOf(node, path)));
60
+ case 'date': {
61
+ const literal = firstLiteralOf(node, path);
62
+ return setNumber(document, field.name, literal === undefined ? undefined : isoToUnixSeconds(literal));
63
+ }
64
+ case 'boolean': {
65
+ // The xsd:boolean lexical space: true/false/1/0.
66
+ const literal = firstLiteralOf(node, path);
67
+ if (literal !== undefined) {
68
+ document[field.name] = literal === 'true' || literal === '1';
69
+ }
70
+ return;
71
+ }
45
72
  }
46
73
  }
47
- function applyLangText(document, values, { name, locales, display, search, sort }) {
48
- if (locales.length === 0) {
49
- throw new Error(`langText field “${name}” must declare at least one locale; nothing would be projected otherwise.`);
50
- }
51
- for (const locale of locales) {
74
+ /**
75
+ * Project a text field per locale (untagged literals land in the `und`
76
+ * locale). Display shows one label (accents preserved) when the field is
77
+ * `output`; sort keys off that same primary value (folded) when `sortable`;
78
+ * search folds every value of the locale when `searchable`, so all are
79
+ * matchable. Absent locales emit nothing.
80
+ */
81
+ function applyText(document, values, field) {
82
+ // Empty `locales` is rejected at declaration time (`validateSearchType`);
83
+ // here it simply projects nothing.
84
+ const locales = field.locales;
85
+ const names = physicalFields(field);
86
+ locales.forEach((locale, index) => {
52
87
  const localeValues = values
53
88
  .filter((value) => value.lang === locale)
54
89
  .map((value) => value.value);
55
90
  if (localeValues.length === 0) {
56
- continue;
91
+ return;
57
92
  }
58
- // Display shows one label (accents preserved); sort keys off that same
59
- // primary value (folded); search folds every value of the locale so all
60
- // are matchable. Absent locales emit nothing (the field stays optional).
61
93
  const [primary] = localeValues;
62
- if (display) {
63
- setString(document, `${name}_${locale}`, primary);
94
+ if (field.output) {
95
+ setString(document, names.display[index], primary);
64
96
  }
65
- if (search) {
66
- setString(document, `${name}_search_${locale}`, fold(localeValues.join(' ')).trim());
97
+ if (field.searchable) {
98
+ setString(document, names.search[index], foldedSearchValue(localeValues));
67
99
  }
68
- if (sort) {
69
- setString(document, `${name}_sort_${locale}`, fold(primary));
100
+ if (field.sortable) {
101
+ setString(document, names.sort[index], fold(primary));
70
102
  }
71
- }
103
+ });
104
+ }
105
+ /** The projection’s definition of a folded free-text search value. */
106
+ function foldedSearchValue(values) {
107
+ return fold(values.join(' ')).trim();
72
108
  }
73
- function applyFacet(document, node, { name, path, iri, search, transform }) {
74
- const raw = iri ? irisOf(node, path) : literalsOf(node, path);
75
- const values = dedupe(transform ? raw.map(transform) : raw);
76
- setArray(document, name, values);
77
- if (search) {
78
- setArray(document, `${name}_search`, dedupe(values.map((value) => fold(value))));
109
+ /**
110
+ * Project a faceted multi-value field: dedupe (after the optional transform),
111
+ * write the value field, and — when `searchable` — a folded `${name}_search`
112
+ * array. `keyword` reads literals; `reference` reads IRIs (the caller passes the
113
+ * already-read raw values).
114
+ */
115
+ function applyFacet(document, raw, field) {
116
+ const values = dedupe(field.transform ? raw.map(field.transform) : raw);
117
+ setArray(document, field.name, values);
118
+ if (field.searchable) {
119
+ setArray(document, physicalFields(field).search[0], dedupe(values.map((value) => fold(value))));
79
120
  }
80
121
  }
81
122
  function langValuesOf(node, path) {
@@ -108,9 +149,10 @@ function toLangValue(value) {
108
149
  if (literal === undefined) {
109
150
  return undefined;
110
151
  }
152
+ // Untagged literals (JSON-LD @none) land in the reserved `und` locale.
111
153
  const lang = isObject(value) && typeof value['@language'] === 'string'
112
154
  ? value['@language']
113
- : '';
155
+ : 'und';
114
156
  return { value: literal, lang };
115
157
  }
116
158
  function literalString(value) {
@@ -140,12 +182,8 @@ function iriString(value) {
140
182
  function toInteger(literal) {
141
183
  return literal === undefined ? undefined : Math.trunc(Number(literal));
142
184
  }
143
- function isoToUnix(iso) {
144
- if (iso === undefined) {
145
- return undefined;
146
- }
147
- const millis = new Date(iso).getTime();
148
- return Number.isNaN(millis) ? undefined : Math.trunc(millis / 1000);
185
+ function toNumber(literal) {
186
+ return literal === undefined ? undefined : Number(literal);
149
187
  }
150
188
  function setNumber(document, field, value) {
151
189
  if (value !== undefined && !Number.isNaN(value)) {
@@ -0,0 +1,87 @@
1
+ import { type SearchType } from './schema.js';
2
+ /**
3
+ * The engine- and protocol-neutral query IR. Every API surface compiles its
4
+ * input into this; every engine adapter compiles it into an engine query. One
5
+ * shared representation in the middle keeps the GraphQL surface, a later REST
6
+ * surface and the adapter from drifting.
7
+ */
8
+ export interface SearchQuery {
9
+ /** Free-text query; `undefined`/`''` means browse (no text ranking). */
10
+ readonly text?: string;
11
+ /** AND across fields. */
12
+ readonly where: readonly Filter[];
13
+ /** Primary public sort plus any server tie-breaks, in precedence order. */
14
+ readonly orderBy: readonly Sort[];
15
+ /** Numbered pagination. */
16
+ readonly limit: number;
17
+ readonly offset: number;
18
+ /** Logical field names to return facet buckets for. */
19
+ readonly facets: readonly string[];
20
+ /** Selects the per-locale fields to query/sort on (from `Accept-Language`). */
21
+ readonly locale: string;
22
+ }
23
+ /**
24
+ * One `where` clause. The operator is fixed by the target field’s {@link FieldKind}
25
+ * ({@link filterOperatorFor}): keyword/reference use `in` (OR within the field),
26
+ * the numeric/date kinds use an inclusive `range`, boolean uses `is`. Bounds are
27
+ * inclusive only — no `gt`/`gte`/`lt`/`lte`.
28
+ */
29
+ export type Filter = {
30
+ readonly field: string;
31
+ readonly in: readonly string[];
32
+ } | {
33
+ readonly field: string;
34
+ readonly range: {
35
+ readonly min?: number | string;
36
+ readonly max?: number | string;
37
+ };
38
+ } | {
39
+ readonly field: string;
40
+ readonly is: boolean;
41
+ };
42
+ /** A single sort dimension. */
43
+ export interface Sort {
44
+ readonly field: string;
45
+ readonly direction: 'asc' | 'desc';
46
+ }
47
+ export { filterOperatorFor } from './schema.js';
48
+ export type { FilterOperator } from './schema.js';
49
+ import type { FilterOperator } from './schema.js';
50
+ /** The operator a {@link Filter} value carries, from its discriminating key. */
51
+ export declare function filterOperator(filter: Filter): FilterOperator;
52
+ /**
53
+ * One structural problem {@link validateQuery} found: the query references a
54
+ * field the search type does not declare, or uses it in a role it does not
55
+ * opt into. Vacuous-but-valid clauses (an empty `in` list, a `range` with no
56
+ * bound) are NOT issues — a compiler skips those as no-ops.
57
+ */
58
+ export interface QueryIssue {
59
+ readonly part: 'where' | 'facets' | 'orderBy';
60
+ readonly field: string;
61
+ readonly reason: 'unknown-field' | 'not-filterable' | 'operator-mismatch' | 'not-facetable';
62
+ }
63
+ /**
64
+ * Structurally validate a query against its search type: every `where` clause
65
+ * targets a declared, `filterable` field with the operator its kind accepts
66
+ * ({@link filterOperatorFor}); every requested facet is a declared, `facetable`
67
+ * field; every sort is `relevance` or a declared field. Sorting deliberately
68
+ * checks declaration only, not the `sortable` flag: that flag means *publicly
69
+ * selectable*, and a deployment policy may sort on a private tie-break field.
70
+ *
71
+ * This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
72
+ * reject a query with issues ({@link assertValidQuery}) instead of passing
73
+ * garbage to its engine, so validation holds for every caller — including
74
+ * `queryDefaults` policies and surfaces weaker than GraphQL.
75
+ */
76
+ export declare function validateQuery(query: SearchQuery, searchType: SearchType): readonly QueryIssue[];
77
+ /** Throw on the first structurally invalid query part ({@link validateQuery}),
78
+ * naming every issue. The always-on entry point for engine adapters. */
79
+ export declare function assertValidQuery(query: SearchQuery, searchType: SearchType): void;
80
+ /**
81
+ * The 1-based page an `offset` falls on — the numbered-pagination presentation
82
+ * of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
83
+ * query) fetches no hits and has no meaningful page, so it pins to 1 rather
84
+ * than dividing by zero.
85
+ */
86
+ export declare function pageForOffset(offset: number, limit: number): number;
87
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAE7E;;;;;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;;;;;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,EACX,eAAe,GACf,gBAAgB,GAChB,mBAAmB,GACnB,eAAe,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,SAAS,UAAU,EAAE,CA6CvB;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 ADDED
@@ -0,0 +1,89 @@
1
+ import { fieldNamed, filterOperatorFor } from './schema.js';
2
+ // The kind→operator table lives with the field model in schema.ts (one source
3
+ // for the query IR, the surfaces, the adapters AND declaration validation);
4
+ // re-exported here so the query-IR module stays the natural import site.
5
+ export { filterOperatorFor } from './schema.js';
6
+ /** The operator a {@link Filter} value carries, from its discriminating key. */
7
+ export function filterOperator(filter) {
8
+ return 'in' in filter ? 'in' : 'range' in filter ? 'range' : 'is';
9
+ }
10
+ /**
11
+ * Structurally validate a query against its search type: every `where` clause
12
+ * targets a declared, `filterable` field with the operator its kind accepts
13
+ * ({@link filterOperatorFor}); every requested facet is a declared, `facetable`
14
+ * field; every sort is `relevance` or a declared field. Sorting deliberately
15
+ * checks declaration only, not the `sortable` flag: that flag means *publicly
16
+ * selectable*, and a deployment policy may sort on a private tie-break field.
17
+ *
18
+ * This is the port’s always-on guard: every {@link SearchEngine} adapter MUST
19
+ * reject a query with issues ({@link assertValidQuery}) instead of passing
20
+ * garbage to its engine, so validation holds for every caller — including
21
+ * `queryDefaults` policies and surfaces weaker than GraphQL.
22
+ */
23
+ export function validateQuery(query, searchType) {
24
+ const issues = [];
25
+ for (const filter of query.where) {
26
+ const field = fieldNamed(searchType, filter.field);
27
+ if (field === undefined) {
28
+ issues.push({
29
+ part: 'where',
30
+ field: filter.field,
31
+ reason: 'unknown-field',
32
+ });
33
+ }
34
+ else if (field.filterable !== true) {
35
+ issues.push({
36
+ part: 'where',
37
+ field: filter.field,
38
+ reason: 'not-filterable',
39
+ });
40
+ }
41
+ else if (filterOperatorFor(field.kind) !== filterOperator(filter)) {
42
+ issues.push({
43
+ part: 'where',
44
+ field: filter.field,
45
+ reason: 'operator-mismatch',
46
+ });
47
+ }
48
+ }
49
+ for (const name of query.facets) {
50
+ const field = fieldNamed(searchType, name);
51
+ if (field === undefined) {
52
+ issues.push({ part: 'facets', field: name, reason: 'unknown-field' });
53
+ }
54
+ else if (field.facetable !== true) {
55
+ issues.push({ part: 'facets', field: name, reason: 'not-facetable' });
56
+ }
57
+ }
58
+ for (const sort of query.orderBy) {
59
+ if (sort.field !== 'relevance' &&
60
+ fieldNamed(searchType, sort.field) === undefined) {
61
+ issues.push({
62
+ part: 'orderBy',
63
+ field: sort.field,
64
+ reason: 'unknown-field',
65
+ });
66
+ }
67
+ }
68
+ return issues;
69
+ }
70
+ /** Throw on the first structurally invalid query part ({@link validateQuery}),
71
+ * naming every issue. The always-on entry point for engine adapters. */
72
+ export function assertValidQuery(query, searchType) {
73
+ const issues = validateQuery(query, searchType);
74
+ if (issues.length > 0) {
75
+ const detail = issues
76
+ .map((issue) => `${issue.part}: “${issue.field}” (${issue.reason})`)
77
+ .join(', ');
78
+ throw new Error(`Invalid search query for “${searchType.name}”: ${detail}.`);
79
+ }
80
+ }
81
+ /**
82
+ * The 1-based page an `offset` falls on — the numbered-pagination presentation
83
+ * of the IR, shared by the surfaces and the adapters. `limit: 0` (a facet-only
84
+ * query) fetches no hits and has no meaningful page, so it pins to 1 rather
85
+ * than dividing by zero.
86
+ */
87
+ export function pageForOffset(offset, limit) {
88
+ return limit > 0 ? Math.floor(offset / limit) + 1 : 1;
89
+ }