@lde/search 0.1.2 → 0.3.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.
@@ -0,0 +1,343 @@
1
+ import type { FramedNode } from './frame-by-type.js';
2
+ import type { SearchDocument } from './project.js';
3
+ /**
4
+ * The engine-neutral kind of a queryable field. It drives every downstream
5
+ * behavior: which physical fields the projection emits, the engine
6
+ * collection-schema type, the `where`/facet/sort semantics, and the GraphQL
7
+ * output/input type. The Typesense-vocabulary types (`string`, `int32`, …) are
8
+ * *derived* from this by the engine adapter, never declared here.
9
+ */
10
+ export type FieldKind = 'text' | 'keyword' | 'integer' | 'number' | 'boolean' | 'date' | 'reference';
11
+ /** The `where` operator a kind accepts, or `undefined` when it is not filterable
12
+ * through `where` (`text` feeds the free-text `query` instead). */
13
+ export type FilterOperator = 'in' | 'range' | 'is';
14
+ /**
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
17
+ * than `where`. The ONE source for the surface’s `where` input type, the
18
+ * adapter’s filter compiler and declaration validation, so they cannot drift.
19
+ */
20
+ export declare function filterOperatorFor(kind: FieldKind): FilterOperator | undefined;
21
+ /**
22
+ * One queryable field — the single declarative source that drives all four
23
+ * consumers (projection, engine collection schema, query semantics, and the
24
+ * GraphQL surface).
25
+ *
26
+ * A **discriminated union by `kind`**: each kind declares exactly the
27
+ * properties it can honour (`ref` on references, `locales` on text,
28
+ * `facetRanges` on numerics), so an illegal declaration fails to compile.
29
+ * {@link validateSearchType} enforces the same rules at runtime for
30
+ * declarations built outside TypeScript (a SHACL generator, plain JS).
31
+ *
32
+ * Capability flags (`searchable`/`filterable`/`facetable`/`sortable`/`output`)
33
+ * are independent opt-ins: a field exposes exactly the roles it declares. A
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
37
+ * behavior (e.g. `status`, the compatibility booleans).
38
+ *
39
+ * The physical field names a declaration fans out to (per-locale search/sort
40
+ * keys) follow one convention, owned by
41
+ * {@link physicalFields} so projection, collection-schema and query compiler
42
+ * cannot disagree.
43
+ *
44
+ * SHACL is one possible *source*, not a dependency: a generator can emit a
45
+ * declaration from a NodeShape + `search:` annotations
46
+ * (`kind`←`sh:datatype`/`sh:nodeKind`, `path`←`sh:path`, `array`←`sh:maxCount`,
47
+ * `locales`←`sh:languageIn` (plus `und` where plain strings are allowed),
48
+ * `ref`←`sh:node`/`sh:class`),
49
+ * and a hand-written declaration is just as valid.
50
+ */
51
+ export type SearchField = TextField | KeywordField | ReferenceField | NumericField | BooleanField;
52
+ /** The declaration members every {@link SearchField} kind shares. */
53
+ export interface SearchFieldBase {
54
+ /** Logical API name; the physical fanout derives from it. Declare camelCase
55
+ * where it surfaces in GraphQL. */
56
+ readonly name: string;
57
+ /** Framed-IR predicate IRI to project from. Omit for a field populated by
58
+ * {@link SearchFieldBase.derive} (or outside the projection entirely). */
59
+ readonly path?: string;
60
+ /** Multi-valued. */
61
+ readonly array?: boolean;
62
+ /** Always present: a non-null scalar in the API output and
63
+ * a non-optional field in the engine index. Moot for arrays/booleans/`id`,
64
+ * which are non-null regardless. */
65
+ readonly required?: boolean;
66
+ /** Appears in the API output type / carries a display label. */
67
+ readonly output?: boolean;
68
+ /** Usable in `where`. */
69
+ readonly filterable?: boolean;
70
+ /** Returned as facet buckets. */
71
+ readonly facetable?: boolean;
72
+ /** Publicly selectable in `orderBy`; localized text also emits a folded sort key. */
73
+ readonly sortable?: boolean;
74
+ /**
75
+ * Compute this field’s value instead of projecting it from a `path` — a
76
+ * status token, a compatibility boolean, a count over the framed node.
77
+ * Mutually exclusive with `path`. Runs in declaration order during
78
+ * projection, receiving the framed node and the document as populated so
79
+ * far, so a derived field may read fields declared before it (e.g. a
80
+ * `statusRank` reading the derived `status`). Return `undefined` to leave
81
+ * the field absent. The field still carries full query/schema/output
82
+ * behaviour like any other.
83
+ */
84
+ readonly derive?: (node: FramedNode, document: SearchDocument) => unknown;
85
+ }
86
+ /** Full-text inclusion with a `query_by` weight (folded; per-locale for
87
+ * localized text). Presence is what makes a field searchable. */
88
+ export interface Searchable {
89
+ readonly searchable?: {
90
+ readonly weight: number;
91
+ };
92
+ }
93
+ /**
94
+ * Free-running text (prose), always multilingual in shape: projected per
95
+ * locale into display/search/sort companions. `locales` lists the language
96
+ * tags to emit; the reserved locale **`und`** (JSON-LD `@none`, RDF `und`)
97
+ * buckets untagged literals, so a monolingual or untagged corpus declares
98
+ * `locales: ['und']` and mixed data `['nl', 'und']` — one mechanism, and
99
+ * adding a language later is additive (the API output shape never changes).
100
+ * Declaring a real language is RECOMMENDED where the data has one: it drives
101
+ * the engine’s per-locale stemming; `und` is folded but unstemmed (unless the
102
+ * deployment’s `defaultLocale` opts in). Feeds the free-text query rather
103
+ * than `where`/facets, so it is deliberately not filterable or facetable. Use
104
+ * {@link KeywordField} only for exact-match tokens, never for prose.
105
+ */
106
+ export interface TextField extends SearchFieldBase, Searchable {
107
+ readonly kind: 'text';
108
+ /** The locales to emit (per-locale fanout); at least one. `und` = untagged. */
109
+ readonly locales: readonly string[];
110
+ readonly filterable?: never;
111
+ readonly facetable?: never;
112
+ readonly facetRanges?: never;
113
+ }
114
+ /** An exact-match token or free string: filtered by membership, faceted per
115
+ * value, searchable folded. */
116
+ export interface KeywordField extends SearchFieldBase, Searchable {
117
+ readonly kind: 'keyword';
118
+ /** Projection-time value transform (e.g. strip a media-type prefix). */
119
+ readonly transform?: (value: string) => string;
120
+ readonly facetRanges?: never;
121
+ }
122
+ /** An IRI-valued reference to another entity, label-resolved at the surface. */
123
+ export interface ReferenceField extends SearchFieldBase, Searchable {
124
+ readonly kind: 'reference';
125
+ /** Projection-time value transform. */
126
+ readonly transform?: (value: string) => string;
127
+ readonly facetRanges?: never;
128
+ /** The referenced entity’s shape and how much of it to carry. Required when
129
+ * the field is `output` (the API surfaces need the reference type name);
130
+ * optional for a facet- or filter-only reference. */
131
+ readonly ref?: {
132
+ /** Logical API type name of the referenced entity (PascalCase, e.g.
133
+ * `Organization`) — names the reference’s type in the API surfaces, the
134
+ * way {@link SearchType.name} names a root type; fields sharing it share
135
+ * one emitted type. A name, not a key: it need not correspond to any
136
+ * indexed root type (and until cross-collection references exist, it must
137
+ * not collide with one). */
138
+ readonly typeName: string;
139
+ /** How much of the referenced entity the reference carries. Only
140
+ * `labelOnly` (id + display label) is implemented; `idOnly` and `inline`
141
+ * are forward declarations, so that declarations (and the SHACL
142
+ * `search:nestedStrategy` mapping) keep their shape when those land, and
143
+ * `inline` can then add fields to the reference type additively. */
144
+ readonly strategy: 'labelOnly' | 'idOnly' | 'inline';
145
+ };
146
+ }
147
+ /**
148
+ * Range-facet bins for a numeric (`integer`/`number`/`date`) facetable field.
149
+ * When set, the field facets into these fixed half-open `[min, max)` ranges (a
150
+ * histogram) rather than one bucket per distinct value — the per-bucket counts
151
+ * a UI slider needs. Bins are query-time only (no index impact) and
152
+ * engine-neutral: the Typesense adapter emits a `facet_by` range, an
153
+ * OpenSearch adapter a `range` aggregation. See {@link FacetRange}.
154
+ */
155
+ export interface RangeFacetable {
156
+ readonly facetRanges?: readonly FacetRange[];
157
+ }
158
+ /**
159
+ * A numeric value: range-filtered, range- or value-faceted, sortable.
160
+ * `integer` is a whole number, `number` a float, `date` a point in time
161
+ * (ISO 8601 at the edges, Unix seconds in the index) — identical capabilities,
162
+ * so one interface serves all three kinds; `field.kind` still narrows.
163
+ */
164
+ export interface NumericField extends SearchFieldBase, RangeFacetable {
165
+ readonly kind: 'integer' | 'number' | 'date';
166
+ readonly searchable?: never;
167
+ }
168
+ /** A boolean flag; absent in a document means `false`. */
169
+ export interface BooleanField extends SearchFieldBase {
170
+ readonly kind: 'boolean';
171
+ readonly searchable?: never;
172
+ readonly facetRanges?: never;
173
+ }
174
+ /**
175
+ * One half-open `[min, max)` range-facet bin: `min` inclusive, `max` exclusive,
176
+ * so contiguous bins partition cleanly with no boundary double-counting. Omit
177
+ * `min` (or `max`) for an open-ended bin (`< max`, resp. `≥ min`). `key` is the
178
+ * bucket’s stable label, echoed back as the {@link FacetBucket} `value`.
179
+ */
180
+ export interface FacetRange {
181
+ readonly key: string;
182
+ readonly min?: number;
183
+ readonly max?: number;
184
+ }
185
+ /**
186
+ * One root type’s complete search declaration: its logical API `name`, the
187
+ * `type` IRI its documents are instances of, and the queryable `fields`
188
+ * (including {@link SearchField.derive derived} ones). A SHACL generator can
189
+ * emit one per NodeShape (`name`←`sh:name`/local name, `type`←`sh:targetClass`,
190
+ * `fields`←its property shapes), but that is a source, not a requirement.
191
+ */
192
+ export interface SearchType {
193
+ /** Logical API name (PascalCase, e.g. `Dataset`) — names the type in the API
194
+ * surfaces (GraphQL type names, a REST path), the way each field’s
195
+ * {@link SearchField.name} names that field. Deliberately declared rather
196
+ * than derived from the `type` IRI, so re-modelling the vocabulary cannot
197
+ * silently rename the public contract. */
198
+ readonly name: string;
199
+ readonly type: string;
200
+ readonly fields: readonly SearchField[];
201
+ }
202
+ /**
203
+ * Declare a {@link SearchType}, capturing it as a literal: the `const` type
204
+ * parameter preserves the field names and capability flags that the type-level
205
+ * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type —
206
+ * with none of the widening a plain `: SearchType` annotation causes and
207
+ * without having to remember `as const satisfies SearchType`. Identity at
208
+ * runtime.
209
+ */
210
+ export declare function defineSearchType<const Type extends SearchType>(searchType: Type): Type;
211
+ /**
212
+ * The complete search declaration of a deployment: every root {@link SearchType},
213
+ * keyed by its `type` IRI. Build one with {@link searchSchema}, which captures
214
+ * the declared types as a literal tuple (`Types`), so schema-bound consumers
215
+ * (the engine port) can type their per-type behaviour off it. A plain
216
+ * `: SearchSchema` annotation widens gracefully to `SearchType`.
217
+ */
218
+ /** Brand for {@link SearchSchema}: type-only, no runtime existence. Makes the
219
+ * schema NOMINAL — a hand-built `Map` is not assignable, so `searchSchema()`
220
+ * (which validates) is the only way to obtain one and downstream consumers
221
+ * need no defensive re-validation. */
222
+ export declare const validSearchSchema: unique symbol;
223
+ export interface SearchSchema<Types extends readonly SearchType[] = readonly SearchType[]> extends ReadonlyMap<string, Types[number]> {
224
+ readonly [validSearchSchema]: true;
225
+ }
226
+ /**
227
+ * Build a {@link SearchSchema} from root-type declarations, keyed by `type`.
228
+ *
229
+ * Every declaration is validated ({@link assertValidSearchType}) — the
230
+ * declaration-time counterpart of the port’s `assertValidQuery` — and the
231
+ * schema-wide invariants are enforced: no two types may share a `type` IRI
232
+ * (they would silently overwrite each other in the map) or a `name` (names
233
+ * key the API surfaces). Throws on the first invalid declaration, so a bad
234
+ * schema fails at startup, not per document at index time or per query.
235
+ */
236
+ export declare function searchSchema<const Types extends readonly SearchType[]>(...types: Types): SearchSchema<Types>;
237
+ /**
238
+ * One structural problem {@link validateSearchType} found: a field declares a
239
+ * capability or property its `kind` cannot honour, or the declaration is
240
+ * internally inconsistent. Each reason names the field-level rule it violates;
241
+ * the rules mirror the per-kind semantics table in the README.
242
+ */
243
+ export interface SearchTypeIssue {
244
+ readonly field: string;
245
+ 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';
246
+ }
247
+ /**
248
+ * Structurally validate one {@link SearchType} declaration — the
249
+ * declaration-time counterpart of `validateQuery`. Rules:
250
+ *
251
+ * - field names are unique (a duplicate would silently shadow in every
252
+ * consumer, each picking a different winner);
253
+ * - a `reference` field that is `output` declares `ref` (the API surfaces
254
+ * need the reference type name); `ref` on any other kind is meaningless;
255
+ * - a `text` field declares at least one locale (`und` = untagged; projection and
256
+ * result reconstruction have no representation for unlocalized text — use
257
+ * `keyword` for untagged strings); `locales` on any other kind is
258
+ * meaningless;
259
+ * - a kind without a `where` operator (`text` — it feeds the free-text query)
260
+ * is neither `filterable` nor `facetable`;
261
+ * - `facetRanges` only on the `range`-operator kinds (`integer`/`number`/`date`);
262
+ * - `searchable` only on `text`/`keyword`/`reference` (projection emits no
263
+ * folded search field for the other kinds);
264
+ * - `transform` only on `keyword`/`reference` (the only kinds whose
265
+ * projection applies it);
266
+ * - `derive` and `path` are mutually exclusive (a field is projected or
267
+ * computed, never both).
268
+ *
269
+ * Pure and total: returns every issue rather than throwing;
270
+ * {@link assertValidSearchType} is the throwing entry point.
271
+ */
272
+ export declare function validateSearchType(searchType: SearchType): readonly SearchTypeIssue[];
273
+ /**
274
+ * Throw when `searchType` is not a member of `schema` — the port membership
275
+ * guard every engine adapter applies before searching, so a query can never
276
+ * meet an index the deployment did not declare. Identity-based: the exact
277
+ * declaration object must be in the schema, not a lookalike.
278
+ */
279
+ export declare function assertTypeInSchema(schema: SearchSchema, searchType: SearchType): void;
280
+ /** Throw on a structurally invalid {@link SearchType} ({@link validateSearchType}),
281
+ * naming every issue. Called by {@link searchSchema} for each declaration. */
282
+ export declare function assertValidSearchType(searchType: SearchType): void;
283
+ /**
284
+ * The physical engine fields one {@link SearchField} fans out into, grouped by
285
+ * the role each plays. The single source of truth for the naming convention, so
286
+ * the projection (writes them), the collection schema (declares them) and the
287
+ * query compiler (reads them) cannot disagree.
288
+ */
289
+ export interface PhysicalFields {
290
+ /** Per-locale output labels `${name}_${locale}` (localized text, `output`). */
291
+ readonly display: readonly string[];
292
+ /** Folded match fields: `${name}_search_${locale}` per locale (localized) or a
293
+ * single `${name}_search` (non-localized), when `searchable`. */
294
+ readonly search: readonly string[];
295
+ /** Per-locale folded sort keys `${name}_sort_${locale}` (localized text,
296
+ * `sortable`); a non-localized field sorts on its own `name` field. */
297
+ readonly sort: readonly string[];
298
+ }
299
+ /**
300
+ * Full-text searchable fields, highest `query_by` weight first — the order the
301
+ * engine adapter weights `query_by` in. A field is searchable iff it carries a
302
+ * `searchable` weight.
303
+ */
304
+ export declare function searchableFields(searchType: SearchType): readonly (SearchField & {
305
+ readonly searchable: {
306
+ readonly weight: number;
307
+ };
308
+ })[];
309
+ /** Fields returned as facet buckets, in declaration order. */
310
+ export declare function facetableFields(searchType: SearchType): readonly SearchField[];
311
+ /** Fields usable in `where`, in declaration order. */
312
+ export declare function filterableFields(searchType: SearchType): readonly SearchField[];
313
+ /** Fields publicly selectable in `orderBy`, in declaration order. */
314
+ export declare function sortableFields(searchType: SearchType): readonly SearchField[];
315
+ /** Fields that appear in the API output type, in declaration order. */
316
+ export declare function outputFields(searchType: SearchType): readonly SearchField[];
317
+ /** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */
318
+ export declare function referenceFields(searchType: SearchType): readonly SearchField[];
319
+ /** Look up a field by its logical name. */
320
+ export declare function fieldNamed(searchType: SearchType, name: string): SearchField | undefined;
321
+ /**
322
+ * Whether a facet on this field returns fixed range bins (a histogram) rather
323
+ * than one bucket per distinct value: it declares non-empty
324
+ * {@link RangeFacetable.facetRanges}. One predicate for the surface’s facet
325
+ * type, the adapter’s facet clause and the bucket reconstruction, so they
326
+ * cannot disagree.
327
+ */
328
+ export declare function isRangeFacet(field: SearchField): field is NumericField & {
329
+ readonly facetRanges: readonly FacetRange[];
330
+ };
331
+ /**
332
+ * The engine storage codec for `date` fields: stored as Unix seconds (a
333
+ * sortable, range-filterable int64), ISO 8601 at the API edges. One pair for
334
+ * the projection (writes), the query compiler (filter bounds) and the surface
335
+ * (output), so the three cannot disagree. Returns `undefined` for an
336
+ * unparseable value.
337
+ */
338
+ export declare function isoToUnixSeconds(iso: string): number | undefined;
339
+ /** The inverse of {@link isoToUnixSeconds}: stored Unix seconds → ISO 8601. */
340
+ export declare function unixSecondsToIso(seconds: number): string;
341
+ /** Derive the physical engine field names a declaration produces. */
342
+ export declare function physicalFields(field: SearchField): PhysicalFields;
343
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/dist/schema.js ADDED
@@ -0,0 +1,250 @@
1
+ const OPERATOR_BY_KIND = {
2
+ text: undefined,
3
+ keyword: 'in',
4
+ reference: 'in',
5
+ integer: 'range',
6
+ number: 'range',
7
+ date: 'range',
8
+ boolean: 'is',
9
+ };
10
+ /**
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
13
+ * than `where`. The ONE source for the surface’s `where` input type, the
14
+ * adapter’s filter compiler and declaration validation, so they cannot drift.
15
+ */
16
+ export function filterOperatorFor(kind) {
17
+ return OPERATOR_BY_KIND[kind];
18
+ }
19
+ /**
20
+ * Declare a {@link SearchType}, capturing it as a literal: the `const` type
21
+ * parameter preserves the field names and capability flags that the type-level
22
+ * helpers (`FacetFieldsOf`, `OutputFieldsOf`) read off the type —
23
+ * with none of the widening a plain `: SearchType` annotation causes and
24
+ * without having to remember `as const satisfies SearchType`. Identity at
25
+ * runtime.
26
+ */
27
+ export function defineSearchType(searchType) {
28
+ return searchType;
29
+ }
30
+ /**
31
+ * Build a {@link SearchSchema} from root-type declarations, keyed by `type`.
32
+ *
33
+ * Every declaration is validated ({@link assertValidSearchType}) — the
34
+ * declaration-time counterpart of the port’s `assertValidQuery` — and the
35
+ * schema-wide invariants are enforced: no two types may share a `type` IRI
36
+ * (they would silently overwrite each other in the map) or a `name` (names
37
+ * key the API surfaces). Throws on the first invalid declaration, so a bad
38
+ * schema fails at startup, not per document at index time or per query.
39
+ */
40
+ export function searchSchema(...types) {
41
+ const typeIris = new Set();
42
+ const names = new Set();
43
+ for (const searchType of types) {
44
+ assertValidSearchType(searchType);
45
+ if (typeIris.has(searchType.type)) {
46
+ throw new Error(`Duplicate search type IRI “${searchType.type}”; each SearchType must declare a distinct type.`);
47
+ }
48
+ if (names.has(searchType.name)) {
49
+ throw new Error(`Duplicate search type name “${searchType.name}”; each SearchType must declare a distinct name.`);
50
+ }
51
+ typeIris.add(searchType.type);
52
+ names.add(searchType.name);
53
+ }
54
+ // The one blessed cast: only this validated constructor mints the brand.
55
+ return new Map(types.map((searchType) => [searchType.type, searchType]));
56
+ }
57
+ /** Kinds that can feed full-text search (project a folded search field). */
58
+ const SEARCHABLE_KINDS = ['text', 'keyword', 'reference'];
59
+ /** Kinds whose projection applies the {@link KeywordField.transform}. */
60
+ const TRANSFORMABLE_KINDS = ['keyword', 'reference'];
61
+ /**
62
+ * Structurally validate one {@link SearchType} declaration — the
63
+ * declaration-time counterpart of `validateQuery`. Rules:
64
+ *
65
+ * - field names are unique (a duplicate would silently shadow in every
66
+ * consumer, each picking a different winner);
67
+ * - a `reference` field that is `output` declares `ref` (the API surfaces
68
+ * need the reference type name); `ref` on any other kind is meaningless;
69
+ * - a `text` field declares at least one locale (`und` = untagged; projection and
70
+ * result reconstruction have no representation for unlocalized text — use
71
+ * `keyword` for untagged strings); `locales` on any other kind is
72
+ * meaningless;
73
+ * - a kind without a `where` operator (`text` — it feeds the free-text query)
74
+ * is neither `filterable` nor `facetable`;
75
+ * - `facetRanges` only on the `range`-operator kinds (`integer`/`number`/`date`);
76
+ * - `searchable` only on `text`/`keyword`/`reference` (projection emits no
77
+ * folded search field for the other kinds);
78
+ * - `transform` only on `keyword`/`reference` (the only kinds whose
79
+ * projection applies it);
80
+ * - `derive` and `path` are mutually exclusive (a field is projected or
81
+ * computed, never both).
82
+ *
83
+ * Pure and total: returns every issue rather than throwing;
84
+ * {@link assertValidSearchType} is the throwing entry point.
85
+ */
86
+ export function validateSearchType(searchType) {
87
+ const issues = [];
88
+ const seen = new Set();
89
+ for (const declared of searchType.fields) {
90
+ // Validation guards declarations built OUTSIDE TypeScript (a SHACL
91
+ // generator, plain JS), so it inspects the uniform flat shape rather
92
+ // than trusting the discriminated union.
93
+ const field = declared;
94
+ const issue = (reason) => issues.push({ field: field.name, reason });
95
+ if (seen.has(field.name)) {
96
+ issue('duplicate-field-name');
97
+ }
98
+ seen.add(field.name);
99
+ if (field.kind === 'reference') {
100
+ if (field.output === true && field.ref === undefined) {
101
+ issue('missing-ref');
102
+ }
103
+ }
104
+ else if (field.ref !== undefined) {
105
+ issue('ref-not-allowed');
106
+ }
107
+ if (field.kind === 'text') {
108
+ if ((field.locales ?? []).length === 0) {
109
+ issue('text-requires-locales');
110
+ }
111
+ }
112
+ else if (field.locales !== undefined) {
113
+ issue('locales-not-allowed');
114
+ }
115
+ // Derived from the kind→operator table, so validation, the surfaces and
116
+ // the compilers cannot disagree: a kind without a `where` operator (text)
117
+ // is neither filterable nor facetable, and only `range` kinds bin.
118
+ if (field.filterable === true &&
119
+ filterOperatorFor(field.kind) === undefined) {
120
+ issue('text-not-filterable');
121
+ }
122
+ if (field.facetable === true &&
123
+ filterOperatorFor(field.kind) === undefined) {
124
+ issue('text-not-facetable');
125
+ }
126
+ if (field.facetRanges !== undefined &&
127
+ filterOperatorFor(field.kind) !== 'range') {
128
+ issue('facet-ranges-not-allowed');
129
+ }
130
+ if (field.searchable !== undefined &&
131
+ !SEARCHABLE_KINDS.includes(field.kind)) {
132
+ issue('searchable-not-allowed');
133
+ }
134
+ if (field.transform !== undefined &&
135
+ !TRANSFORMABLE_KINDS.includes(field.kind)) {
136
+ issue('transform-not-allowed');
137
+ }
138
+ if (field.derive !== undefined && field.path !== undefined) {
139
+ issue('derive-with-path');
140
+ }
141
+ }
142
+ return issues;
143
+ }
144
+ /**
145
+ * Throw when `searchType` is not a member of `schema` — the port membership
146
+ * guard every engine adapter applies before searching, so a query can never
147
+ * meet an index the deployment did not declare. Identity-based: the exact
148
+ * declaration object must be in the schema, not a lookalike.
149
+ */
150
+ export function assertTypeInSchema(schema, searchType) {
151
+ if (schema.get(searchType.type) !== searchType) {
152
+ throw new Error(`Search type “${searchType.name}” is not in this engine’s schema; it serves ${[
153
+ ...schema.values(),
154
+ ]
155
+ .map((declared) => `“${declared.name}”`)
156
+ .join(', ')}.`);
157
+ }
158
+ }
159
+ /** Throw on a structurally invalid {@link SearchType} ({@link validateSearchType}),
160
+ * naming every issue. Called by {@link searchSchema} for each declaration. */
161
+ export function assertValidSearchType(searchType) {
162
+ const issues = validateSearchType(searchType);
163
+ if (issues.length > 0) {
164
+ const detail = issues
165
+ .map((issue) => `“${issue.field}” (${issue.reason})`)
166
+ .join(', ');
167
+ throw new Error(`Invalid search type “${searchType.name}”: ${detail}.`);
168
+ }
169
+ }
170
+ /**
171
+ * Full-text searchable fields, highest `query_by` weight first — the order the
172
+ * engine adapter weights `query_by` in. A field is searchable iff it carries a
173
+ * `searchable` weight.
174
+ */
175
+ export function searchableFields(searchType) {
176
+ return searchType.fields
177
+ .filter((field) => field.searchable !== undefined)
178
+ .sort((left, right) => right.searchable.weight - left.searchable.weight);
179
+ }
180
+ /** Fields returned as facet buckets, in declaration order. */
181
+ export function facetableFields(searchType) {
182
+ return searchType.fields.filter((field) => field.facetable === true);
183
+ }
184
+ /** Fields usable in `where`, in declaration order. */
185
+ export function filterableFields(searchType) {
186
+ return searchType.fields.filter((field) => field.filterable === true);
187
+ }
188
+ /** Fields publicly selectable in `orderBy`, in declaration order. */
189
+ export function sortableFields(searchType) {
190
+ return searchType.fields.filter((field) => field.sortable === true);
191
+ }
192
+ /** Fields that appear in the API output type, in declaration order. */
193
+ export function outputFields(searchType) {
194
+ return searchType.fields.filter((field) => field.output === true);
195
+ }
196
+ /** Fields of kind `reference` (IRI-valued, label-resolved), in declaration order. */
197
+ export function referenceFields(searchType) {
198
+ return searchType.fields.filter((field) => field.kind === 'reference');
199
+ }
200
+ /** Look up a field by its logical name. */
201
+ export function fieldNamed(searchType, name) {
202
+ return searchType.fields.find((field) => field.name === name);
203
+ }
204
+ /**
205
+ * Whether a facet on this field returns fixed range bins (a histogram) rather
206
+ * than one bucket per distinct value: it declares non-empty
207
+ * {@link RangeFacetable.facetRanges}. One predicate for the surface’s facet
208
+ * type, the adapter’s facet clause and the bucket reconstruction, so they
209
+ * cannot disagree.
210
+ */
211
+ export function isRangeFacet(field) {
212
+ return field.facetRanges !== undefined && field.facetRanges.length > 0;
213
+ }
214
+ /**
215
+ * The engine storage codec for `date` fields: stored as Unix seconds (a
216
+ * sortable, range-filterable int64), ISO 8601 at the API edges. One pair for
217
+ * the projection (writes), the query compiler (filter bounds) and the surface
218
+ * (output), so the three cannot disagree. Returns `undefined` for an
219
+ * unparseable value.
220
+ */
221
+ export function isoToUnixSeconds(iso) {
222
+ const millis = new Date(iso).getTime();
223
+ return Number.isNaN(millis) ? undefined : Math.trunc(millis / 1000);
224
+ }
225
+ /** The inverse of {@link isoToUnixSeconds}: stored Unix seconds → ISO 8601. */
226
+ export function unixSecondsToIso(seconds) {
227
+ return new Date(seconds * 1000).toISOString();
228
+ }
229
+ /** Derive the physical engine field names a declaration produces. */
230
+ export function physicalFields(field) {
231
+ if (field.kind === 'text') {
232
+ const locales = field.locales;
233
+ return {
234
+ display: field.output
235
+ ? locales.map((locale) => `${field.name}_${locale}`)
236
+ : [],
237
+ search: field.searchable
238
+ ? locales.map((locale) => `${field.name}_search_${locale}`)
239
+ : [],
240
+ sort: field.sortable
241
+ ? locales.map((locale) => `${field.name}_sort_${locale}`)
242
+ : [],
243
+ };
244
+ }
245
+ return {
246
+ display: [],
247
+ search: field.searchable !== undefined ? [`${field.name}_search`] : [],
248
+ sort: [],
249
+ };
250
+ }
@@ -0,0 +1,19 @@
1
+ import type { SearchEngine } from './engine.js';
2
+ /**
3
+ * The executable {@link SearchEngine} port contract (import from
4
+ * `@lde/search/testing`): a vitest suite every engine adapter runs against a
5
+ * live instance of itself, so the port rules hold by test rather than by
6
+ * prose. Covers schema binding (a type outside the bound schema is rejected),
7
+ * the always-on query validation (a structurally invalid query is rejected
8
+ * before it reaches the engine) and the result shape of a browse query and a
9
+ * `searchFacets` batch – for every type in the schema.
10
+ *
11
+ * ```ts
12
+ * describeSearchEngineContract('TypesenseSearchEngine', () => engine);
13
+ * ```
14
+ *
15
+ * `engine` is a thunk so the instance may be created in a `beforeAll` (e.g.
16
+ * against a testcontainer); it is called inside each test.
17
+ */
18
+ export declare function describeSearchEngineContract(name: string, engine: () => SearchEngine): void;
19
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAahD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAsJN"}