@lde/search-typesense 0.1.0 → 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/README.md +41 -14
- package/dist/adapter.d.ts +17 -9
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +10 -5
- package/dist/collection-schema.d.ts +30 -0
- package/dist/collection-schema.d.ts.map +1 -0
- package/dist/collection-schema.js +105 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/query-compiler.d.ts +42 -0
- package/dist/query-compiler.d.ts.map +1 -0
- package/dist/query-compiler.js +193 -0
- package/dist/search.d.ts +82 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +301 -0
- package/package.json +5 -3
- package/dist/frame.d.ts +0 -28
- package/dist/frame.d.ts.map +0 -1
- package/dist/frame.js +0 -42
package/README.md
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
# @lde/search-typesense
|
|
2
2
|
|
|
3
|
-
[Typesense](https://typesense.org/) engine adapter for
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[Typesense](https://typesense.org/) engine adapter for the engine- and
|
|
4
|
+
domain-agnostic [`@lde/search`](../search) core. **Engine-specific (Typesense) but
|
|
5
|
+
domain-agnostic** – you supply a `SearchType`; this package never names your
|
|
6
|
+
domain. It is the Typesense implementation of the `SearchEngine` port: it derives
|
|
7
|
+
a collection schema from the field model, compiles the neutral `SearchQuery` into
|
|
8
|
+
Typesense search params, runs it, reconstructs the engine-neutral `SearchResult`,
|
|
9
|
+
and manages the search index lifecycle (blue/green rebuild).
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
## Collection schema and engine
|
|
12
|
+
|
|
13
|
+
`buildCollectionSchema(searchType, { name, defaultSortingField, … })` derives a
|
|
14
|
+
Typesense collection from the unified `SearchField` model — the Typesense field
|
|
15
|
+
type comes from each field’s `kind`, and the physical fanout (per-locale
|
|
16
|
+
search/sort keys) matches what the projection writes, via
|
|
17
|
+
`@lde/search`’s `physicalFields`, so the index and the documents cannot drift.
|
|
18
|
+
|
|
19
|
+
`createTypesenseSearchEngine(client, { collection, labelsCollection })` is the
|
|
20
|
+
`SearchEngine` implementation. Each search:
|
|
21
|
+
|
|
22
|
+
- validates the query against the search type (the port contract — a
|
|
23
|
+
structurally invalid query is rejected, never sent);
|
|
24
|
+
- compiles it into Typesense search params (`buildSearchParams`);
|
|
25
|
+
- runs the search;
|
|
26
|
+
- resolves reference (and reference-facet) labels from the sidecar `labels`
|
|
27
|
+
collection in a single lookup;
|
|
28
|
+
- reconstructs the logical `SearchResult` (`parseSearchResponse`) — language
|
|
29
|
+
maps, labelled references, labelled facet buckets.
|
|
30
|
+
|
|
31
|
+
The pure halves `buildSearchParams` and `parseSearchResponse` are exported for
|
|
32
|
+
direct use and testing.
|
|
11
33
|
|
|
12
34
|
## Indexing
|
|
13
35
|
|
|
14
|
-
`rebuild` blue/green-rebuilds a search index in one call
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`
|
|
19
|
-
|
|
36
|
+
`rebuild` blue/green-rebuilds a search index in one call, straight from the
|
|
37
|
+
declaration: it derives the collection schema from your `SearchType` (via
|
|
38
|
+
`buildCollectionSchema`), creates a fresh versioned collection
|
|
39
|
+
(`${name}_<timestamp>`), streams the documents into it in batches, atomically
|
|
40
|
+
repoints the `name` alias to it, then drops the collection it superseded. The
|
|
41
|
+
caller passes the `SearchType`, the logical index `name` and a stream of
|
|
42
|
+
documents; the versioned collection and the alias are managed for them.
|
|
20
43
|
|
|
21
44
|
```ts
|
|
22
45
|
import { Client } from 'typesense';
|
|
@@ -30,9 +53,13 @@ const client = new Client({
|
|
|
30
53
|
// `documents` is an async iterable (e.g. a streaming projection); only one
|
|
31
54
|
// batch is held in memory at a time. `rebuild` returns the live collection name
|
|
32
55
|
// and the imported count (or `null` if another rebuild was already running).
|
|
33
|
-
const result = await rebuild(client,
|
|
56
|
+
const result = await rebuild(client, documents, DATASET, { name: 'datasets' });
|
|
34
57
|
```
|
|
35
58
|
|
|
59
|
+
The options accept everything `buildCollectionSchema` does (`defaultLocale`,
|
|
60
|
+
`defaultSortingField`, `synonymSets`) plus the rebuild knobs (`batchSize`,
|
|
61
|
+
`lockTtlMs`).
|
|
62
|
+
|
|
36
63
|
`rebuild` takes a `Client` the caller owns (and reuses for queries), so this
|
|
37
64
|
package adds no connection or document type of its own – any object with an `id`
|
|
38
65
|
is a valid document, including the `SearchDocument`s `@lde/search` produces.
|
package/dist/adapter.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import type { Client
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import type { SearchType } from '@lde/search';
|
|
3
|
+
import { type CollectionSchemaOptions } from './collection-schema.js';
|
|
4
|
+
/** {@link rebuild} options: the collection-schema options (`name` is the
|
|
5
|
+
* logical index name the alias is kept on) plus the rebuild tuning knobs. */
|
|
6
|
+
export interface RebuildOptions extends CollectionSchemaOptions {
|
|
7
|
+
/** Documents imported per Typesense request (default 1000). */
|
|
8
|
+
readonly batchSize?: number;
|
|
9
|
+
/** A held lock older than this (ms) is reclaimed (default 10 minutes). */
|
|
10
|
+
readonly lockTtlMs?: number;
|
|
11
|
+
}
|
|
2
12
|
/**
|
|
3
|
-
* Blue/green-rebuild the search index `name
|
|
13
|
+
* Blue/green-rebuild the search index `options.name` from one declarative
|
|
14
|
+
* source: the collection schema is derived from `searchType`
|
|
15
|
+
* ({@link buildCollectionSchema}) and the documents are streamed in — one call
|
|
16
|
+
* from declaration to live index.
|
|
4
17
|
*
|
|
5
|
-
* 1. create a fresh versioned collection (`${name}_<timestamp>`)
|
|
18
|
+
* 1. create a fresh versioned collection (`${name}_<timestamp>`)
|
|
6
19
|
* 2. stream `documents` into it in batches
|
|
7
20
|
* 3. atomically repoint the `name` alias to the new collection, then
|
|
8
21
|
* drop the collection it superseded. The caller passes only the logical
|
|
@@ -36,12 +49,7 @@ import type { Client, CollectionCreateSchema } from 'typesense';
|
|
|
36
49
|
*/
|
|
37
50
|
export declare function rebuild<Document extends {
|
|
38
51
|
id: string;
|
|
39
|
-
}>(client: Client,
|
|
40
|
-
/** Documents imported per Typesense request (default 1000). */
|
|
41
|
-
batchSize?: number;
|
|
42
|
-
/** A held lock older than this (ms) is reclaimed (default 10 minutes). */
|
|
43
|
-
lockTtlMs?: number;
|
|
44
|
-
}): Promise<{
|
|
52
|
+
}>(client: Client, documents: AsyncIterable<Document>, searchType: SearchType, options: RebuildOptions): Promise<{
|
|
45
53
|
collection: string;
|
|
46
54
|
imported: number;
|
|
47
55
|
} | null>;
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAEL,KAAK,uBAAuB,EAC7B,MAAM,wBAAwB,CAAC;AAKhC;8EAC8E;AAC9E,MAAM,WAAW,cAAe,SAAQ,uBAAuB;IAC7D,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,OAAO,CAAC,QAAQ,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,EAC3D,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,aAAa,CAAC,QAAQ,CAAC,EAClC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAwC1D"}
|
package/dist/adapter.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { buildCollectionSchema, } from './collection-schema.js';
|
|
1
2
|
const LOCK_COLLECTION = 'rebuild_locks';
|
|
2
3
|
const DEFAULT_LOCK_TTL_MS = 10 * 60 * 1000;
|
|
3
4
|
/**
|
|
4
|
-
* Blue/green-rebuild the search index `name
|
|
5
|
+
* Blue/green-rebuild the search index `options.name` from one declarative
|
|
6
|
+
* source: the collection schema is derived from `searchType`
|
|
7
|
+
* ({@link buildCollectionSchema}) and the documents are streamed in — one call
|
|
8
|
+
* from declaration to live index.
|
|
5
9
|
*
|
|
6
|
-
* 1. create a fresh versioned collection (`${name}_<timestamp>`)
|
|
10
|
+
* 1. create a fresh versioned collection (`${name}_<timestamp>`)
|
|
7
11
|
* 2. stream `documents` into it in batches
|
|
8
12
|
* 3. atomically repoint the `name` alias to the new collection, then
|
|
9
13
|
* drop the collection it superseded. The caller passes only the logical
|
|
@@ -35,9 +39,10 @@ const DEFAULT_LOCK_TTL_MS = 10 * 60 * 1000;
|
|
|
35
39
|
* minutes) can be reclaimed by another caller and run concurrently; size the
|
|
36
40
|
* TTL above your longest rebuild.
|
|
37
41
|
*/
|
|
38
|
-
export async function rebuild(client,
|
|
39
|
-
const { batchSize = 1000, lockTtlMs = DEFAULT_LOCK_TTL_MS } = options;
|
|
40
|
-
const
|
|
42
|
+
export async function rebuild(client, documents, searchType, options) {
|
|
43
|
+
const { batchSize = 1000, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...schemaOptions } = options;
|
|
44
|
+
const schema = buildCollectionSchema(searchType, schemaOptions);
|
|
45
|
+
const name = schemaOptions.name;
|
|
41
46
|
if (!(await acquireLock(client, name, lockTtlMs))) {
|
|
42
47
|
return null;
|
|
43
48
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CollectionCreateSchema } from 'typesense';
|
|
2
|
+
import { type SearchType } from '@lde/search';
|
|
3
|
+
/** Deployment-specific options the generic field model does not carry. */
|
|
4
|
+
export interface CollectionSchemaOptions {
|
|
5
|
+
/** The Typesense collection (or alias) name. */
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** Snowball stemming locale for non-localized searchable fields (e.g. `en`).
|
|
8
|
+
* Unset, those fields are not stemmed — folding still applies — so no
|
|
9
|
+
* language is ever assumed. Localized text search fields always stem in
|
|
10
|
+
* their own locale. */
|
|
11
|
+
readonly defaultLocale?: string;
|
|
12
|
+
/** The field Typesense sorts by when a query imposes no order. */
|
|
13
|
+
readonly defaultSortingField?: string;
|
|
14
|
+
/** Synonym sets the collection references (synced separately). */
|
|
15
|
+
readonly synonymSets?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a Typesense collection schema from the unified {@link SearchType}, so
|
|
19
|
+
* the index and the projection are driven by one declarative source and cannot
|
|
20
|
+
* drift. Each field fans out into the same physical fields the projection writes
|
|
21
|
+
* ({@link physicalFields}); the Typesense field type is derived from the field
|
|
22
|
+
* `kind`, never re-declared.
|
|
23
|
+
*
|
|
24
|
+
* Text stems each folded `*_search_${locale}` field in its own language; the
|
|
25
|
+
* untagged `und` locale (and any searchable keyword/reference companion)
|
|
26
|
+
* stems in `defaultLocale` when one is set, and is left unstemmed (folded
|
|
27
|
+
* only) otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildCollectionSchema(searchType: SearchType, options: CollectionSchemaOptions): CollectionCreateSchema;
|
|
30
|
+
//# sourceMappingURL=collection-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"collection-schema.d.ts","sourceRoot":"","sources":["../src/collection-schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGhE,0EAA0E;AAC1E,MAAM,WAAW,uBAAuB;IACtC,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,uBAAuB,GAC/B,sBAAsB,CAexB"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { physicalFields } from '@lde/search/adapter';
|
|
2
|
+
/**
|
|
3
|
+
* Build a Typesense collection schema from the unified {@link SearchType}, so
|
|
4
|
+
* the index and the projection are driven by one declarative source and cannot
|
|
5
|
+
* drift. Each field fans out into the same physical fields the projection writes
|
|
6
|
+
* ({@link physicalFields}); the Typesense field type is derived from the field
|
|
7
|
+
* `kind`, never re-declared.
|
|
8
|
+
*
|
|
9
|
+
* Text stems each folded `*_search_${locale}` field in its own language; the
|
|
10
|
+
* untagged `und` locale (and any searchable keyword/reference companion)
|
|
11
|
+
* stems in `defaultLocale` when one is set, and is left unstemmed (folded
|
|
12
|
+
* only) otherwise.
|
|
13
|
+
*/
|
|
14
|
+
export function buildCollectionSchema(searchType, options) {
|
|
15
|
+
const { defaultLocale } = options;
|
|
16
|
+
const collection = {
|
|
17
|
+
name: options.name,
|
|
18
|
+
fields: searchType.fields.flatMap((field) => typesenseFields(field, defaultLocale, options.defaultSortingField)),
|
|
19
|
+
};
|
|
20
|
+
if (options.defaultSortingField !== undefined) {
|
|
21
|
+
collection.default_sorting_field = options.defaultSortingField;
|
|
22
|
+
}
|
|
23
|
+
if (options.synonymSets !== undefined) {
|
|
24
|
+
collection.synonym_sets = [...options.synonymSets];
|
|
25
|
+
}
|
|
26
|
+
return collection;
|
|
27
|
+
}
|
|
28
|
+
/** The physical Typesense fields one declaration produces. */
|
|
29
|
+
function typesenseFields(field, defaultLocale, defaultSortingField) {
|
|
30
|
+
const names = physicalFields(field);
|
|
31
|
+
if (field.kind === 'text') {
|
|
32
|
+
const locales = field.locales;
|
|
33
|
+
return [
|
|
34
|
+
// Display labels: stored, not indexed for search (search uses the folded
|
|
35
|
+
// companions), accents preserved.
|
|
36
|
+
...names.display.map((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
type: 'string',
|
|
39
|
+
index: false,
|
|
40
|
+
optional: true,
|
|
41
|
+
})),
|
|
42
|
+
// One folded search field per locale, each stemmed in its own
|
|
43
|
+
// language; the untagged `und` locale is folded but unstemmed unless
|
|
44
|
+
// the deployment opts in via `defaultLocale`.
|
|
45
|
+
...names.search.map((name, index) => {
|
|
46
|
+
const locale = locales[index] === 'und' ? defaultLocale : locales[index];
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
type: 'string',
|
|
50
|
+
optional: true,
|
|
51
|
+
...(locale !== undefined && { stem: true, locale }),
|
|
52
|
+
};
|
|
53
|
+
}),
|
|
54
|
+
...names.sort.map((name) => ({
|
|
55
|
+
name,
|
|
56
|
+
type: 'string',
|
|
57
|
+
sort: true,
|
|
58
|
+
optional: true,
|
|
59
|
+
})),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
const valueType = typesenseValueType(field);
|
|
63
|
+
const fields = [
|
|
64
|
+
{
|
|
65
|
+
name: field.name,
|
|
66
|
+
type: valueType,
|
|
67
|
+
facet: field.facetable ?? false,
|
|
68
|
+
sort: field.sortable ?? false,
|
|
69
|
+
// A `required` field is non-optional; so is the `default_sorting_field`,
|
|
70
|
+
// which Typesense requires to be present. Everything else may be absent.
|
|
71
|
+
optional: field.required !== true && field.name !== defaultSortingField,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
// `names.search` is non-empty exactly when the field projects a folded
|
|
75
|
+
// search companion — physicalFields owns that rule.
|
|
76
|
+
for (const name of names.search) {
|
|
77
|
+
fields.push({
|
|
78
|
+
name,
|
|
79
|
+
type: valueType,
|
|
80
|
+
optional: true,
|
|
81
|
+
...(defaultLocale !== undefined && {
|
|
82
|
+
stem: true,
|
|
83
|
+
locale: defaultLocale,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return fields;
|
|
88
|
+
}
|
|
89
|
+
/** The Typesense field type for a non-localized field, from its `kind`. 64-bit
|
|
90
|
+
* integers (and dates, stored as Unix seconds) so large counts never overflow. */
|
|
91
|
+
function typesenseValueType(field) {
|
|
92
|
+
switch (field.kind) {
|
|
93
|
+
case 'integer':
|
|
94
|
+
case 'date':
|
|
95
|
+
return 'int64';
|
|
96
|
+
case 'number':
|
|
97
|
+
return 'float';
|
|
98
|
+
case 'boolean':
|
|
99
|
+
return 'bool';
|
|
100
|
+
case 'keyword':
|
|
101
|
+
case 'reference':
|
|
102
|
+
case 'text':
|
|
103
|
+
return field.array === true ? 'string[]' : 'string';
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
export { rebuild } from './adapter.js';
|
|
2
|
+
export type { RebuildOptions } from './adapter.js';
|
|
3
|
+
export { buildCollectionSchema } from './collection-schema.js';
|
|
4
|
+
export type { CollectionSchemaOptions } from './collection-schema.js';
|
|
5
|
+
export { buildSearchParams } from './query-compiler.js';
|
|
6
|
+
export type { BuildSearchParamsOptions } from './query-compiler.js';
|
|
7
|
+
export { createTypesenseSearchEngine, parseSearchResponse } from './search.js';
|
|
8
|
+
export type { TypesenseSearchEngineOptions, TypesenseSearchResponse, } from './search.js';
|
|
2
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,YAAY,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,2BAA2B,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EACV,4BAA4B,EAC5B,uBAAuB,GACxB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SearchParams } from 'typesense/lib/Typesense/Documents.js';
|
|
2
|
+
import { type Filter, type SearchQuery, type SearchType } from '@lde/search';
|
|
3
|
+
/**
|
|
4
|
+
* Options for {@link buildSearchParams} — the query half of the engine
|
|
5
|
+
* adapter. {@link TypesenseSearchEngineOptions} extends this, so each knob is
|
|
6
|
+
* declared once and the engine forwards its options wholesale.
|
|
7
|
+
*/
|
|
8
|
+
export interface BuildSearchParamsOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Cap on the number of buckets returned per facet (`max_facet_values`). Left
|
|
11
|
+
* unset, Typesense defaults to 10 — too few for high-cardinality facets
|
|
12
|
+
* (publisher, keyword), so a deployment with such facets must raise it. Range
|
|
13
|
+
* facets return one bucket per declared range regardless, but a value > the
|
|
14
|
+
* range count is still safe.
|
|
15
|
+
*/
|
|
16
|
+
readonly maxFacetValues?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Called for each `where` clause that compiles to nothing and is therefore
|
|
19
|
+
* skipped: an unknown field, an operator that does not match the field’s
|
|
20
|
+
* kind ({@link filterOperatorFor}), an empty `in` list, or a `range` with no
|
|
21
|
+
* usable bound. Skipping keeps a malformed clause from reaching the engine
|
|
22
|
+
* as garbage; supply this to log it instead of losing it silently. Through
|
|
23
|
+
* the engine, a structurally invalid query throws up front
|
|
24
|
+
* (`assertValidQuery`), so there only the vacuous clauses reach this.
|
|
25
|
+
*/
|
|
26
|
+
readonly onIgnoredFilter?: (filter: Filter) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compile the engine-neutral {@link SearchQuery} into Typesense search
|
|
30
|
+
* parameters — the query half of the engine adapter. Pure (no client, no env),
|
|
31
|
+
* so the mapping is asserted directly in unit tests. Field names come from
|
|
32
|
+
* {@link physicalFields}, the same convention the projection and the collection
|
|
33
|
+
* schema use, so a query can never reference a field the index does not carry.
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildSearchParams(query: SearchQuery, searchType: SearchType, options?: BuildSearchParamsOptions): SearchParams<object>;
|
|
36
|
+
/**
|
|
37
|
+
* Backtick-wrap a filter value so reserved characters in IRIs and media types
|
|
38
|
+
* (`:`, `/`, `&`, `,`, …) are taken literally instead of parsed as filter syntax.
|
|
39
|
+
* An embedded backtick is escaped.
|
|
40
|
+
*/
|
|
41
|
+
export declare function escapeFilterValue(value: string): string;
|
|
42
|
+
//# sourceMappingURL=query-compiler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAYrB;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AA2LD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { fold } from '@lde/text-normalization';
|
|
2
|
+
import { fieldNamed, filterOperator, filterOperatorFor, isoToUnixSeconds, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
|
|
3
|
+
/**
|
|
4
|
+
* Compile the engine-neutral {@link SearchQuery} into Typesense search
|
|
5
|
+
* parameters — the query half of the engine adapter. Pure (no client, no env),
|
|
6
|
+
* so the mapping is asserted directly in unit tests. Field names come from
|
|
7
|
+
* {@link physicalFields}, the same convention the projection and the collection
|
|
8
|
+
* schema use, so a query can never reference a field the index does not carry.
|
|
9
|
+
*/
|
|
10
|
+
export function buildSearchParams(query, searchType, options = {}) {
|
|
11
|
+
const folded = query.text !== undefined && query.text.length > 0
|
|
12
|
+
? fold(query.text)
|
|
13
|
+
: undefined;
|
|
14
|
+
const { names, weights } = queryFields(searchType, query.locale);
|
|
15
|
+
const filterBy = compileFilterBy(query.where, searchType, options.onIgnoredFilter);
|
|
16
|
+
const sortBy = query.orderBy
|
|
17
|
+
.map((sort) => compileSort(sort, searchType, query.locale))
|
|
18
|
+
.join(',');
|
|
19
|
+
const params = {
|
|
20
|
+
q: folded ?? '*',
|
|
21
|
+
query_by: names.join(','),
|
|
22
|
+
query_by_weights: weights.join(','),
|
|
23
|
+
per_page: query.limit,
|
|
24
|
+
page: pageForOffset(query.offset, query.limit),
|
|
25
|
+
};
|
|
26
|
+
if (filterBy.length > 0) {
|
|
27
|
+
params.filter_by = filterBy;
|
|
28
|
+
}
|
|
29
|
+
if (sortBy.length > 0) {
|
|
30
|
+
params.sort_by = sortBy;
|
|
31
|
+
}
|
|
32
|
+
if (query.facets.length > 0) {
|
|
33
|
+
params.facet_by = compileFacetBy(query.facets, searchType);
|
|
34
|
+
if (options.maxFacetValues !== undefined) {
|
|
35
|
+
params.max_facet_values = options.maxFacetValues;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return params;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The `facet_by` clause. A facet on a numeric field that declares
|
|
42
|
+
* {@link SearchField.facetRanges} faceted into those fixed half-open `[min, max)`
|
|
43
|
+
* bins (a histogram); every other facet is a plain per-value facet on its field
|
|
44
|
+
* name. Typesense range syntax is already start-inclusive/end-exclusive, so the
|
|
45
|
+
* declared bounds pass straight through with no boundary fix-up.
|
|
46
|
+
*/
|
|
47
|
+
function compileFacetBy(facets, searchType) {
|
|
48
|
+
return facets
|
|
49
|
+
.map((name) => {
|
|
50
|
+
const field = fieldNamed(searchType, name);
|
|
51
|
+
return field !== undefined && isRangeFacet(field)
|
|
52
|
+
? compileRangeFacet(field.name, field.facetRanges)
|
|
53
|
+
: name;
|
|
54
|
+
})
|
|
55
|
+
.join(',');
|
|
56
|
+
}
|
|
57
|
+
/** `name(key:[min, max], …)`; a blank bound is open-ended (Typesense `[75, ]`). */
|
|
58
|
+
function compileRangeFacet(name, ranges) {
|
|
59
|
+
const bins = ranges
|
|
60
|
+
.map((range) => `${range.key}:[${range.min ?? ''}, ${range.max ?? ''}]`)
|
|
61
|
+
.join(', ');
|
|
62
|
+
return `${name}(${bins})`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The `query_by` fields and aligned weights. Each searchable field expands to its
|
|
66
|
+
* folded `*_search` companion(s); a localized field’s active-locale companion
|
|
67
|
+
* keeps its full weight while the other locale is gently demoted (−1, floored at
|
|
68
|
+
* 1), so a match in the user’s language ranks higher while cross-language matches
|
|
69
|
+
* still surface.
|
|
70
|
+
*/
|
|
71
|
+
function queryFields(searchType, locale) {
|
|
72
|
+
const names = [];
|
|
73
|
+
const weights = [];
|
|
74
|
+
for (const field of searchableFields(searchType)) {
|
|
75
|
+
const search = physicalFields(field).search;
|
|
76
|
+
const baseWeight = field.searchable.weight;
|
|
77
|
+
if (field.kind === 'text') {
|
|
78
|
+
const locales = field.locales;
|
|
79
|
+
search.forEach((name, index) => {
|
|
80
|
+
names.push(name);
|
|
81
|
+
// The active locale keeps full weight; `und` is language-neutral, so
|
|
82
|
+
// it is never demoted (an untagged-only field would otherwise always
|
|
83
|
+
// rank below its declared weight).
|
|
84
|
+
weights.push(locales[index] === locale || locales[index] === 'und'
|
|
85
|
+
? baseWeight
|
|
86
|
+
: Math.max(1, baseWeight - 1));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
for (const name of search) {
|
|
91
|
+
names.push(name);
|
|
92
|
+
weights.push(baseWeight);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { names, weights };
|
|
97
|
+
}
|
|
98
|
+
/** AND-join the compiled `where` clauses; a clause that compiles to nothing is
|
|
99
|
+
* skipped and reported to `onIgnoredFilter`. */
|
|
100
|
+
function compileFilterBy(where, searchType, onIgnoredFilter) {
|
|
101
|
+
return where
|
|
102
|
+
.map((filter) => {
|
|
103
|
+
const clause = compileFilter(filter, searchType);
|
|
104
|
+
if (clause === undefined) {
|
|
105
|
+
onIgnoredFilter?.(filter);
|
|
106
|
+
}
|
|
107
|
+
return clause;
|
|
108
|
+
})
|
|
109
|
+
.filter((clause) => clause !== undefined)
|
|
110
|
+
.join(' && ');
|
|
111
|
+
}
|
|
112
|
+
function compileFilter(filter, searchType) {
|
|
113
|
+
const field = fieldNamed(searchType, filter.field);
|
|
114
|
+
if (field === undefined) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
// A clause whose operator does not match the field's kind (e.g. `range` on a
|
|
118
|
+
// keyword) would reach the engine as garbage syntax — skip it instead.
|
|
119
|
+
if (filterOperatorFor(field.kind) !== filterOperator(filter)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if ('in' in filter) {
|
|
123
|
+
return filter.in.length > 0
|
|
124
|
+
? compileMembership(field, filter.in)
|
|
125
|
+
: undefined;
|
|
126
|
+
}
|
|
127
|
+
if ('range' in filter) {
|
|
128
|
+
return compileRange(field, filter.range);
|
|
129
|
+
}
|
|
130
|
+
return `${field.name}:=${filter.is}`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A membership clause. A non-facet (tokenized) field uses the exact `:=`
|
|
134
|
+
* operator so an IRI cannot partial-match on a shared path segment.
|
|
135
|
+
*/
|
|
136
|
+
function compileMembership(field, values) {
|
|
137
|
+
const list = `[${values.map(escapeFilterValue).join(',')}]`;
|
|
138
|
+
return field.facetable !== true
|
|
139
|
+
? `${field.name}:=${list}`
|
|
140
|
+
: `${field.name}:${list}`;
|
|
141
|
+
}
|
|
142
|
+
/** An inclusive Typesense range clause, or `undefined` when neither bound is set. */
|
|
143
|
+
function compileRange(field, range) {
|
|
144
|
+
const name = field.name;
|
|
145
|
+
const min = storedBound(field, range.min);
|
|
146
|
+
const max = storedBound(field, range.max);
|
|
147
|
+
if (min !== undefined && max !== undefined) {
|
|
148
|
+
return `${name}:[${min}..${max}]`;
|
|
149
|
+
}
|
|
150
|
+
if (min !== undefined) {
|
|
151
|
+
return `${name}:>=${min}`;
|
|
152
|
+
}
|
|
153
|
+
if (max !== undefined) {
|
|
154
|
+
return `${name}:<=${max}`;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/** A range bound as stored: a `date` field’s ISO 8601 bound becomes the indexed
|
|
159
|
+
* Unix seconds ({@link isoToUnixSeconds}); an unparseable bound is dropped. */
|
|
160
|
+
function storedBound(field, bound) {
|
|
161
|
+
return field.kind === 'date' && typeof bound === 'string'
|
|
162
|
+
? isoToUnixSeconds(bound)
|
|
163
|
+
: bound;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* One `sort_by` term. `relevance` maps to Typesense’s `_text_match`; a localized
|
|
167
|
+
* text field sorts on its active-locale folded key; any other field (including a
|
|
168
|
+
* deployment tie-break like `status_rank`) sorts on its own name.
|
|
169
|
+
*/
|
|
170
|
+
function compileSort(sort, searchType, locale) {
|
|
171
|
+
if (sort.field === 'relevance') {
|
|
172
|
+
return `_text_match:${sort.direction}`;
|
|
173
|
+
}
|
|
174
|
+
const field = fieldNamed(searchType, sort.field);
|
|
175
|
+
if (field !== undefined && field.kind === 'text') {
|
|
176
|
+
// Sort on the active locale's key, falling back to the first declared
|
|
177
|
+
// locale (e.g. an und-only field sorted by a user in any language).
|
|
178
|
+
const index = field.locales.indexOf(locale);
|
|
179
|
+
const sortName = physicalFields(field).sort[index === -1 ? 0 : index];
|
|
180
|
+
if (sortName !== undefined) {
|
|
181
|
+
return `${sortName}:${sort.direction}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return `${sort.field}:${sort.direction}`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Backtick-wrap a filter value so reserved characters in IRIs and media types
|
|
188
|
+
* (`:`, `/`, `&`, `,`, …) are taken literally instead of parsed as filter syntax.
|
|
189
|
+
* An embedded backtick is escaped.
|
|
190
|
+
*/
|
|
191
|
+
export function escapeFilterValue(value) {
|
|
192
|
+
return `\`${value.replace(/`/g, '\\`')}\``;
|
|
193
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import { type LocalizedValue, type SearchEngine, type SearchResult, type SearchSchema, type SearchType } from '@lde/search';
|
|
3
|
+
import { type BuildSearchParamsOptions } from './query-compiler.js';
|
|
4
|
+
/** Where the engine reads documents and (optionally) reference labels — plus
|
|
5
|
+
* every query-compiler knob ({@link BuildSearchParamsOptions}), declared once
|
|
6
|
+
* there and forwarded wholesale into each search. `TypeName` is the union of
|
|
7
|
+
* the schema’s type names, so a literal schema makes the `collections` map
|
|
8
|
+
* exhaustive at compile time. */
|
|
9
|
+
export interface TypesenseSearchEngineOptions<TypeName extends string = string> extends BuildSearchParamsOptions {
|
|
10
|
+
/** The collection (or alias) per search type, keyed by the type’s `name` —
|
|
11
|
+
* with a literal schema, omitting a type is a compile error. */
|
|
12
|
+
readonly collections: Readonly<Record<TypeName, string>>;
|
|
13
|
+
/** The sidecar `labels` collection (IRI → label); omit for id-only references. */
|
|
14
|
+
readonly labelsCollection?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Called when reference-label resolution fails; the search then degrades to
|
|
17
|
+
* id-only references rather than failing. Optional — omit to swallow silently.
|
|
18
|
+
*/
|
|
19
|
+
readonly onLabelError?: (error: unknown) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Opt-in in-memory label cache. When set (and {@link labelsCollection} is
|
|
22
|
+
* set), the FULL sidecar `labels` collection is loaded once via the documents
|
|
23
|
+
* export endpoint and held in a process-lifetime cache for this many
|
|
24
|
+
* milliseconds; each `search` then resolves its reference labels by in-memory
|
|
25
|
+
* lookup instead of a per-search `multi_search` round-trip. Omit to keep the
|
|
26
|
+
* per-search {@link fetchLabels} behaviour unchanged.
|
|
27
|
+
*/
|
|
28
|
+
readonly labelCacheTtlMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A Typesense-backed {@link SearchEngine}, bound to the whole
|
|
32
|
+
* {@link SearchSchema} at construction — like every other schema consumer.
|
|
33
|
+
* Each type’s collection comes from `options.collections`; the label sidecar
|
|
34
|
+
* and its cache are shared across all types. `search` compiles the query
|
|
35
|
+
* ({@link buildSearchParams}), runs it against the type’s collection,
|
|
36
|
+
* resolves the reference labels for the page of hits from the sidecar
|
|
37
|
+
* `labels` collection in one lookup, and reconstructs the engine-neutral
|
|
38
|
+
* {@link SearchResult} ({@link parseSearchResponse}). Every engine specific
|
|
39
|
+
* stays here; consumers see only logical documents.
|
|
40
|
+
*
|
|
41
|
+
* With a schema built by `searchSchema` over `defineSearchType` literals,
|
|
42
|
+
* `search()` accepts only the deployment’s own types and returns typo-safe
|
|
43
|
+
* facet/document keys per call — no caller-side generics.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createTypesenseSearchEngine<const Types extends readonly SearchType[]>(client: Client, schema: SearchSchema<Types>, options: TypesenseSearchEngineOptions<Types[number]['name']>): SearchEngine<Types>;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve labels for `iris` from the sidecar `labels` collection. Each
|
|
48
|
+
* `label_${locale}` becomes a language-map entry; the default `label` is the
|
|
49
|
+
* untagged (`und`) fallback when no locale variant exists.
|
|
50
|
+
*
|
|
51
|
+
* Sent as one `multi_search` (POST) call, the id-list split over per-search
|
|
52
|
+
* batches: the id-list of a page or facet carrying many references — e.g. a
|
|
53
|
+
* dataset with dozens of classes — would overflow Typesense’s GET query-string
|
|
54
|
+
* limit (4000 chars, and IRIs URL-encode to several times their length) if it
|
|
55
|
+
* travelled in the URL. POST puts it in the body; each batch stays under
|
|
56
|
+
* Typesense’s `per_page` cap, and bundling the batches keeps it one round-trip
|
|
57
|
+
* regardless of IRI count. Exported for unit testing against a fake client.
|
|
58
|
+
*/
|
|
59
|
+
export declare function fetchLabels(client: Pick<Client, 'multiSearch'>, collection: string, iris: readonly string[]): Promise<Map<string, LocalizedValue>>;
|
|
60
|
+
/** The subset of a Typesense search response this adapter reads. */
|
|
61
|
+
export interface TypesenseSearchResponse {
|
|
62
|
+
readonly found: number;
|
|
63
|
+
readonly hits?: readonly {
|
|
64
|
+
readonly document: Record<string, unknown>;
|
|
65
|
+
}[];
|
|
66
|
+
readonly facet_counts?: readonly {
|
|
67
|
+
readonly field_name: string;
|
|
68
|
+
readonly counts: readonly {
|
|
69
|
+
readonly value: string;
|
|
70
|
+
readonly count: number;
|
|
71
|
+
}[];
|
|
72
|
+
}[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
|
|
76
|
+
* the flat, fanned-out document is turned back into a logical one (per-locale
|
|
77
|
+
* display fields → a language map, reference IRIs → labelled references via the
|
|
78
|
+
* sidecar `labels` lookup, scalars passed through). `labels` maps a reference IRI
|
|
79
|
+
* to its resolved label; an IRI absent from it yields an id-only reference.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>): SearchResult;
|
|
82
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAEL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAGhB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;kCAIkC;AAClC,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;qEACiE;IACjE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,EAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAC3D,YAAY,CAAC,KAAK,CAAC,CAkGrB;AA+DD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAyBtC;AAsBD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAiCd"}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { assertTypeInSchema, assertValidQuery, fieldNamed, isRangeFacet, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
|
|
2
|
+
import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
|
|
3
|
+
/**
|
|
4
|
+
* A Typesense-backed {@link SearchEngine}, bound to the whole
|
|
5
|
+
* {@link SearchSchema} at construction — like every other schema consumer.
|
|
6
|
+
* Each type’s collection comes from `options.collections`; the label sidecar
|
|
7
|
+
* and its cache are shared across all types. `search` compiles the query
|
|
8
|
+
* ({@link buildSearchParams}), runs it against the type’s collection,
|
|
9
|
+
* resolves the reference labels for the page of hits from the sidecar
|
|
10
|
+
* `labels` collection in one lookup, and reconstructs the engine-neutral
|
|
11
|
+
* {@link SearchResult} ({@link parseSearchResponse}). Every engine specific
|
|
12
|
+
* stays here; consumers see only logical documents.
|
|
13
|
+
*
|
|
14
|
+
* With a schema built by `searchSchema` over `defineSearchType` literals,
|
|
15
|
+
* `search()` accepts only the deployment’s own types and returns typo-safe
|
|
16
|
+
* facet/document keys per call — no caller-side generics.
|
|
17
|
+
*/
|
|
18
|
+
export function createTypesenseSearchEngine(client, schema, options) {
|
|
19
|
+
// Resolve every type's collection ONCE at construction, so a
|
|
20
|
+
// misconfiguration fails at startup — never on the first search that
|
|
21
|
+
// happens to hit the unwired type.
|
|
22
|
+
const collections = new Map([...schema.values()].map((searchType) => {
|
|
23
|
+
const collection = options.collections[searchType.name];
|
|
24
|
+
if (collection === undefined) {
|
|
25
|
+
throw new Error(`No collection configured for search type “${searchType.name}”; set options.collections.${searchType.name}.`);
|
|
26
|
+
}
|
|
27
|
+
return [searchType.type, collection];
|
|
28
|
+
}));
|
|
29
|
+
// Process-lifetime cache for the FULL `labels` collection, held in the engine
|
|
30
|
+
// closure. Populated lazily on the first cached search; `loadAll` is the
|
|
31
|
+
// single-flight in-flight promise so concurrent first-loads share one export.
|
|
32
|
+
let cachedLabels;
|
|
33
|
+
let cacheExpiresAt = 0;
|
|
34
|
+
let inFlightLoad;
|
|
35
|
+
function cachedAllLabels(labelsCollection, ttlMs) {
|
|
36
|
+
if (cachedLabels !== undefined && Date.now() < cacheExpiresAt) {
|
|
37
|
+
return Promise.resolve(cachedLabels);
|
|
38
|
+
}
|
|
39
|
+
// Single-flight: a load already running serves every concurrent caller.
|
|
40
|
+
inFlightLoad ??= loadAllLabels(client, labelsCollection)
|
|
41
|
+
.then((loaded) => {
|
|
42
|
+
cachedLabels = loaded;
|
|
43
|
+
cacheExpiresAt = Date.now() + ttlMs;
|
|
44
|
+
return loaded;
|
|
45
|
+
})
|
|
46
|
+
// A failed load degrades to id-only references and is NOT cached, so the
|
|
47
|
+
// next search retries rather than serving an empty map for the whole TTL.
|
|
48
|
+
.catch((error) => {
|
|
49
|
+
options.onLabelError?.(error);
|
|
50
|
+
return new Map();
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
inFlightLoad = undefined;
|
|
54
|
+
});
|
|
55
|
+
return inFlightLoad;
|
|
56
|
+
}
|
|
57
|
+
const engine = {
|
|
58
|
+
schema,
|
|
59
|
+
async search(searchType, query) {
|
|
60
|
+
// The port contract: a type outside the bound schema and a structurally
|
|
61
|
+
// invalid query (unknown field, wrong operator, unknown facet) are both
|
|
62
|
+
// rejected up front, for EVERY caller.
|
|
63
|
+
assertTypeInSchema(schema, searchType);
|
|
64
|
+
assertValidQuery(query, searchType);
|
|
65
|
+
const params = buildSearchParams(query, searchType, options);
|
|
66
|
+
// Cached path: the once-loaded full collection serves labels by in-memory
|
|
67
|
+
// lookup (no per-search round-trip). The load does not depend on the
|
|
68
|
+
// response, so it runs alongside the search; it never rejects (a failed
|
|
69
|
+
// load degrades to an empty map), so it cannot leave an unhandled
|
|
70
|
+
// rejection behind if the search itself fails.
|
|
71
|
+
const cachedLabelsPromise = options.labelsCollection !== undefined &&
|
|
72
|
+
options.labelCacheTtlMs !== undefined
|
|
73
|
+
? cachedAllLabels(options.labelsCollection, options.labelCacheTtlMs)
|
|
74
|
+
: undefined;
|
|
75
|
+
const response = (await client
|
|
76
|
+
.collections(collections.get(searchType.type))
|
|
77
|
+
.documents()
|
|
78
|
+
.search(params));
|
|
79
|
+
// Labels are supplementary: a failed lookup (e.g. the sidecar collection
|
|
80
|
+
// mid-rebuild) degrades to id-only references rather than failing the whole
|
|
81
|
+
// search, so the listing still renders with bare IRIs.
|
|
82
|
+
let labels = new Map();
|
|
83
|
+
if (cachedLabelsPromise !== undefined) {
|
|
84
|
+
labels = await cachedLabelsPromise;
|
|
85
|
+
}
|
|
86
|
+
else if (options.labelsCollection !== undefined) {
|
|
87
|
+
try {
|
|
88
|
+
labels = await fetchLabels(client, options.labelsCollection, referenceIris(response, searchType));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
options.onLabelError?.(error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return parseSearchResponse(response, searchType, labels);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
// The runtime object is string-keyed; the literal-schema typing narrows it.
|
|
98
|
+
return engine;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Load the FULL `labels` collection into a label map via the documents export
|
|
102
|
+
* endpoint, which streams every document as JSONL (one JSON object per line).
|
|
103
|
+
* Each line is reconstructed by {@link labelToLocalizedValue}, exactly as the
|
|
104
|
+
* per-search {@link fetchLabels} path does for its `multi_search` hits.
|
|
105
|
+
*/
|
|
106
|
+
async function loadAllLabels(client, collection) {
|
|
107
|
+
const jsonl = await client.collections(collection).documents().export();
|
|
108
|
+
const labels = new Map();
|
|
109
|
+
for (const line of jsonl.split('\n')) {
|
|
110
|
+
if (line.length === 0) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const document = JSON.parse(line);
|
|
114
|
+
labels.set(String(document.id), labelToLocalizedValue(document));
|
|
115
|
+
}
|
|
116
|
+
return labels;
|
|
117
|
+
}
|
|
118
|
+
/** Every distinct reference IRI whose label the result will actually use. */
|
|
119
|
+
function referenceIris(response, searchType) {
|
|
120
|
+
const referenceFieldSet = new Set(referenceFields(searchType).map((field) => field.name));
|
|
121
|
+
// Hits only carry labels for OUTPUT reference fields: reconstructDocument skips
|
|
122
|
+
// non-output fields, so resolving a non-output reference's hit labels (e.g. a
|
|
123
|
+
// facet-only `class` with dozens of IRIs per hit) is pure waste.
|
|
124
|
+
const outputReferenceFields = referenceFields(searchType)
|
|
125
|
+
.filter((field) => field.output === true)
|
|
126
|
+
.map((field) => field.name);
|
|
127
|
+
const iris = new Set();
|
|
128
|
+
for (const hit of response.hits ?? []) {
|
|
129
|
+
for (const name of outputReferenceFields) {
|
|
130
|
+
const raw = hit.document[name];
|
|
131
|
+
if (Array.isArray(raw)) {
|
|
132
|
+
for (const value of raw) {
|
|
133
|
+
iris.add(String(value));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (typeof raw === 'string') {
|
|
137
|
+
iris.add(raw);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Reference-facet bucket values are IRIs too (incl. facet-only references like
|
|
142
|
+
// `class`); resolve them in the same lookup.
|
|
143
|
+
for (const facet of response.facet_counts ?? []) {
|
|
144
|
+
if (referenceFieldSet.has(facet.field_name)) {
|
|
145
|
+
for (const bucket of facet.counts) {
|
|
146
|
+
iris.add(bucket.value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return [...iris];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Resolve labels for `iris` from the sidecar `labels` collection. Each
|
|
154
|
+
* `label_${locale}` becomes a language-map entry; the default `label` is the
|
|
155
|
+
* untagged (`und`) fallback when no locale variant exists.
|
|
156
|
+
*
|
|
157
|
+
* Sent as one `multi_search` (POST) call, the id-list split over per-search
|
|
158
|
+
* batches: the id-list of a page or facet carrying many references — e.g. a
|
|
159
|
+
* dataset with dozens of classes — would overflow Typesense’s GET query-string
|
|
160
|
+
* limit (4000 chars, and IRIs URL-encode to several times their length) if it
|
|
161
|
+
* travelled in the URL. POST puts it in the body; each batch stays under
|
|
162
|
+
* Typesense’s `per_page` cap, and bundling the batches keeps it one round-trip
|
|
163
|
+
* regardless of IRI count. Exported for unit testing against a fake client.
|
|
164
|
+
*/
|
|
165
|
+
export async function fetchLabels(client, collection, iris) {
|
|
166
|
+
const labels = new Map();
|
|
167
|
+
if (iris.length === 0) {
|
|
168
|
+
return labels;
|
|
169
|
+
}
|
|
170
|
+
const searches = [];
|
|
171
|
+
for (let start = 0; start < iris.length; start += LABEL_BATCH_SIZE) {
|
|
172
|
+
const batch = iris.slice(start, start + LABEL_BATCH_SIZE);
|
|
173
|
+
searches.push({
|
|
174
|
+
collection,
|
|
175
|
+
q: '*',
|
|
176
|
+
query_by: 'label',
|
|
177
|
+
filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
|
|
178
|
+
per_page: batch.length,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const { results } = (await client.multiSearch.perform({ searches }));
|
|
182
|
+
for (const result of results) {
|
|
183
|
+
for (const hit of result.hits ?? []) {
|
|
184
|
+
labels.set(String(hit.document.id), labelToLocalizedValue(hit.document));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return labels;
|
|
188
|
+
}
|
|
189
|
+
/** Typesense caps `per_page` at 250; the multi_search POST body holds the
|
|
190
|
+
* id-list comfortably, so resolve references in batches of this size. */
|
|
191
|
+
const LABEL_BATCH_SIZE = 200;
|
|
192
|
+
/** Turn a `labels` document into a language map (`label_${locale}` → locale). */
|
|
193
|
+
function labelToLocalizedValue(document) {
|
|
194
|
+
const map = {};
|
|
195
|
+
for (const [key, value] of Object.entries(document)) {
|
|
196
|
+
if (key.startsWith('label_') && typeof value === 'string') {
|
|
197
|
+
map[key.slice('label_'.length)] = [value];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(map).length === 0 && typeof document.label === 'string') {
|
|
201
|
+
map.und = [document.label];
|
|
202
|
+
}
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
|
|
207
|
+
* the flat, fanned-out document is turned back into a logical one (per-locale
|
|
208
|
+
* display fields → a language map, reference IRIs → labelled references via the
|
|
209
|
+
* sidecar `labels` lookup, scalars passed through). `labels` maps a reference IRI
|
|
210
|
+
* to its resolved label; an IRI absent from it yields an id-only reference.
|
|
211
|
+
*/
|
|
212
|
+
export function parseSearchResponse(response, searchType, labels) {
|
|
213
|
+
const hits = (response.hits ?? []).map((hit) => ({
|
|
214
|
+
id: String(hit.document.id),
|
|
215
|
+
document: reconstructDocument(hit.document, searchType, labels),
|
|
216
|
+
}));
|
|
217
|
+
// Reference facets are IRI-keyed; their buckets carry a resolved data label.
|
|
218
|
+
// Plain facets (tokens, free strings) carry no label — the consumer owns display.
|
|
219
|
+
const referenceFacets = new Set(referenceFields(searchType).map((field) => field.name));
|
|
220
|
+
const facets = {};
|
|
221
|
+
for (const facet of response.facet_counts ?? []) {
|
|
222
|
+
const labelled = referenceFacets.has(facet.field_name);
|
|
223
|
+
// A range facet echoes the declared range key as the bucket value; look the
|
|
224
|
+
// bin's half-open bounds back up by key so the bucket is self-describing.
|
|
225
|
+
const field = fieldNamed(searchType, facet.field_name);
|
|
226
|
+
const rangesByKey = field !== undefined && isRangeFacet(field)
|
|
227
|
+
? new Map(field.facetRanges.map((range) => [range.key, range]))
|
|
228
|
+
: undefined;
|
|
229
|
+
facets[facet.field_name] = facet.counts.map((bucket) => {
|
|
230
|
+
const label = labelled ? labels.get(bucket.value) : undefined;
|
|
231
|
+
const range = rangesByKey?.get(bucket.value);
|
|
232
|
+
return {
|
|
233
|
+
value: bucket.value,
|
|
234
|
+
count: bucket.count,
|
|
235
|
+
...(label !== undefined ? { label } : {}),
|
|
236
|
+
...(range?.min !== undefined ? { min: range.min } : {}),
|
|
237
|
+
...(range?.max !== undefined ? { max: range.max } : {}),
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return { hits, total: response.found, facets };
|
|
242
|
+
}
|
|
243
|
+
/** Rebuild one logical document from a flat Typesense document. */
|
|
244
|
+
function reconstructDocument(flat, searchType, labels) {
|
|
245
|
+
const document = {};
|
|
246
|
+
for (const field of outputFields(searchType)) {
|
|
247
|
+
const value = logicalValue(flat, field, labels);
|
|
248
|
+
if (value !== undefined) {
|
|
249
|
+
document[field.name] = value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return document;
|
|
253
|
+
}
|
|
254
|
+
function logicalValue(flat, field, labels) {
|
|
255
|
+
switch (field.kind) {
|
|
256
|
+
case 'text':
|
|
257
|
+
return localizedValue(flat, field);
|
|
258
|
+
case 'reference':
|
|
259
|
+
return referenceValue(flat, field, labels);
|
|
260
|
+
case 'keyword': {
|
|
261
|
+
const value = flat[field.name];
|
|
262
|
+
return Array.isArray(value) || typeof value === 'string'
|
|
263
|
+
? value
|
|
264
|
+
: undefined;
|
|
265
|
+
}
|
|
266
|
+
case 'integer':
|
|
267
|
+
case 'number':
|
|
268
|
+
case 'date': {
|
|
269
|
+
const value = flat[field.name];
|
|
270
|
+
return typeof value === 'number' ? value : undefined;
|
|
271
|
+
}
|
|
272
|
+
case 'boolean':
|
|
273
|
+
// A boolean is always present; an absent value means false.
|
|
274
|
+
return flat[field.name] === true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Gather the per-locale display fields back into a language map. */
|
|
278
|
+
function localizedValue(flat, field) {
|
|
279
|
+
const map = {};
|
|
280
|
+
const display = physicalFields(field).display;
|
|
281
|
+
field.locales.forEach((locale, index) => {
|
|
282
|
+
const value = flat[display[index]];
|
|
283
|
+
if (typeof value === 'string') {
|
|
284
|
+
map[locale] = [value];
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return Object.keys(map).length > 0 ? map : undefined;
|
|
288
|
+
}
|
|
289
|
+
/** Map stored reference IRIs to labelled references; id-only when no label. */
|
|
290
|
+
function referenceValue(flat, field, labels) {
|
|
291
|
+
const raw = flat[field.name];
|
|
292
|
+
if (raw === undefined) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
const iris = Array.isArray(raw) ? raw : [String(raw)];
|
|
296
|
+
const references = iris.map((iri) => {
|
|
297
|
+
const label = labels.get(iri);
|
|
298
|
+
return label === undefined ? { id: iri } : { id: iri, label };
|
|
299
|
+
});
|
|
300
|
+
return field.array === true ? references : references[0];
|
|
301
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-typesense",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
7
7
|
"directory": "packages/search-typesense"
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@lde/search": "^0.2.0",
|
|
29
|
+
"@lde/text-normalization": "^0.1.1",
|
|
28
30
|
"tslib": "^2.3.0",
|
|
29
31
|
"typesense": "^3.0.6"
|
|
30
32
|
},
|
|
31
33
|
"devDependencies": {
|
|
32
|
-
"testcontainers": "^12.0.
|
|
34
|
+
"testcontainers": "^12.0.3"
|
|
33
35
|
}
|
|
34
36
|
}
|
package/dist/frame.d.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Quad } from '@rdfjs/types';
|
|
2
|
-
/**
|
|
3
|
-
* A flat Typesense document. `id` is required (Typesense uses it as the document
|
|
4
|
-
* key); every other field is engine-typed scalar data or an array thereof.
|
|
5
|
-
*/
|
|
6
|
-
export type TypesenseDocument = {
|
|
7
|
-
id: string;
|
|
8
|
-
} & Record<string, unknown>;
|
|
9
|
-
export type FrameFieldType = 'string' | 'string[]' | 'int' | 'float' | 'bool' | 'unixtime';
|
|
10
|
-
/**
|
|
11
|
-
* Maps one document field to a single RDF predicate on the framed subject.
|
|
12
|
-
* This is the generic, engine-agnostic half of projection — straight
|
|
13
|
-
* predicate-to-field mappings with datatype coercion. Domain-specific
|
|
14
|
-
* derivations (folding, grouping, cross-graph joins) are the consumer’s job.
|
|
15
|
-
*/
|
|
16
|
-
export interface FrameField {
|
|
17
|
-
readonly field: string;
|
|
18
|
-
readonly predicate: string;
|
|
19
|
-
readonly type: FrameFieldType;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Frame the quads describing one subject into a flat document, pulling each
|
|
23
|
-
* configured field’s value(s) from its predicate and coercing to the field’s
|
|
24
|
-
* type. Single-valued fields take the first object; `string[]` collects all.
|
|
25
|
-
* Predicates with no matching quad are omitted (left to Typesense optionality).
|
|
26
|
-
*/
|
|
27
|
-
export declare function frame(quads: Iterable<Quad>, subject: string, fields: readonly FrameField[]): TypesenseDocument;
|
|
28
|
-
//# sourceMappingURL=frame.d.ts.map
|
package/dist/frame.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../src/frame.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAEzC;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEzE,MAAM,MAAM,cAAc,GACtB,QAAQ,GACR,UAAU,GACV,KAAK,GACL,OAAO,GACP,MAAM,GACN,UAAU,CAAC;AAEf;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;CAC/B;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CACnB,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,EACrB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,SAAS,UAAU,EAAE,GAC5B,iBAAiB,CAoBnB"}
|
package/dist/frame.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Frame the quads describing one subject into a flat document, pulling each
|
|
3
|
-
* configured field’s value(s) from its predicate and coercing to the field’s
|
|
4
|
-
* type. Single-valued fields take the first object; `string[]` collects all.
|
|
5
|
-
* Predicates with no matching quad are omitted (left to Typesense optionality).
|
|
6
|
-
*/
|
|
7
|
-
export function frame(quads, subject, fields) {
|
|
8
|
-
const objectsByPredicate = new Map();
|
|
9
|
-
for (const quad of quads) {
|
|
10
|
-
if (quad.subject.value !== subject) {
|
|
11
|
-
continue;
|
|
12
|
-
}
|
|
13
|
-
const values = objectsByPredicate.get(quad.predicate.value) ?? [];
|
|
14
|
-
values.push(quad.object.value);
|
|
15
|
-
objectsByPredicate.set(quad.predicate.value, values);
|
|
16
|
-
}
|
|
17
|
-
const document = { id: subject };
|
|
18
|
-
for (const { field, predicate, type } of fields) {
|
|
19
|
-
const values = objectsByPredicate.get(predicate);
|
|
20
|
-
if (values === undefined || values.length === 0) {
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
document[field] = coerce(values, type);
|
|
24
|
-
}
|
|
25
|
-
return document;
|
|
26
|
-
}
|
|
27
|
-
function coerce(values, type) {
|
|
28
|
-
switch (type) {
|
|
29
|
-
case 'string':
|
|
30
|
-
return values[0];
|
|
31
|
-
case 'string[]':
|
|
32
|
-
return values;
|
|
33
|
-
case 'int':
|
|
34
|
-
return Math.trunc(Number(values[0]));
|
|
35
|
-
case 'float':
|
|
36
|
-
return Number(values[0]);
|
|
37
|
-
case 'bool':
|
|
38
|
-
return values[0] === 'true' || values[0] === '1';
|
|
39
|
-
case 'unixtime':
|
|
40
|
-
return Math.trunc(new Date(values[0]).getTime() / 1000);
|
|
41
|
-
}
|
|
42
|
-
}
|