@nixxie-cms/search 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +42 -0
- package/dist/declarations/src/AlgoliaSearch.d.ts +17 -0
- package/dist/declarations/src/AlgoliaSearch.d.ts.map +1 -0
- package/dist/declarations/src/ElasticsearchSearch.d.ts +15 -0
- package/dist/declarations/src/ElasticsearchSearch.d.ts.map +1 -0
- package/dist/declarations/src/InMemorySearch.d.ts +18 -0
- package/dist/declarations/src/InMemorySearch.d.ts.map +1 -0
- package/dist/declarations/src/MeilisearchSearch.d.ts +15 -0
- package/dist/declarations/src/MeilisearchSearch.d.ts.map +1 -0
- package/dist/declarations/src/TypesenseSearch.d.ts +22 -0
- package/dist/declarations/src/TypesenseSearch.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +11 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +48 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-search.cjs.d.ts +2 -0
- package/dist/nixxie-cms-search.cjs.js +433 -0
- package/dist/nixxie-cms-search.esm.js +424 -0
- package/package.json +33 -0
- package/src/AlgoliaSearch.ts +97 -0
- package/src/ElasticsearchSearch.ts +96 -0
- package/src/InMemorySearch.ts +101 -0
- package/src/MeilisearchSearch.ts +72 -0
- package/src/TypesenseSearch.ts +98 -0
- package/src/index.ts +44 -0
- package/src/types.ts +71 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieSearchDocument,
|
|
3
|
+
NixxieSearchQuery,
|
|
4
|
+
NixxieSearchResults,
|
|
5
|
+
NixxieSearchService,
|
|
6
|
+
} from '@nixxie-cms/core'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Zero-dependency in-memory search. Tokenises stringified document values and ranks by the
|
|
10
|
+
* number of query terms matched. Intended for development, tests and small datasets — swap in a
|
|
11
|
+
* real engine (Meilisearch/Typesense/Algolia/Elasticsearch) for production.
|
|
12
|
+
*/
|
|
13
|
+
export class InMemorySearch implements NixxieSearchService {
|
|
14
|
+
private indexes = new Map<string, Map<string, NixxieSearchDocument>>()
|
|
15
|
+
|
|
16
|
+
private indexFor(name: string): Map<string, NixxieSearchDocument> {
|
|
17
|
+
let idx = this.indexes.get(name)
|
|
18
|
+
if (!idx) {
|
|
19
|
+
idx = new Map()
|
|
20
|
+
this.indexes.set(name, idx)
|
|
21
|
+
}
|
|
22
|
+
return idx
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async index(
|
|
26
|
+
indexName: string,
|
|
27
|
+
documents: NixxieSearchDocument | NixxieSearchDocument[]
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const idx = this.indexFor(indexName)
|
|
30
|
+
for (const doc of Array.isArray(documents) ? documents : [documents]) {
|
|
31
|
+
if (!doc.id) throw new Error('Search documents must have an `id`')
|
|
32
|
+
idx.set(doc.id, doc)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async remove(indexName: string, id: string): Promise<void> {
|
|
37
|
+
this.indexes.get(indexName)?.delete(id)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async search<T = NixxieSearchDocument>(
|
|
41
|
+
indexName: string,
|
|
42
|
+
query: NixxieSearchQuery
|
|
43
|
+
): Promise<NixxieSearchResults<T>> {
|
|
44
|
+
const start = Date.now()
|
|
45
|
+
const idx = this.indexes.get(indexName)
|
|
46
|
+
const terms = query.q.toLowerCase().split(/\s+/).filter(Boolean)
|
|
47
|
+
const matched: { document: NixxieSearchDocument; score: number }[] = []
|
|
48
|
+
|
|
49
|
+
for (const doc of idx?.values() ?? []) {
|
|
50
|
+
if (query.filter && !this.matchesFilter(doc, query.filter)) continue
|
|
51
|
+
const haystack = JSON.stringify(doc).toLowerCase()
|
|
52
|
+
const score = terms.length === 0 ? 1 : terms.filter(t => haystack.includes(t)).length
|
|
53
|
+
if (terms.length === 0 || score > 0) matched.push({ document: doc, score })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
matched.sort((a, b) => b.score - a.score)
|
|
57
|
+
if (query.sort?.length) this.applySort(matched, query.sort)
|
|
58
|
+
|
|
59
|
+
const offset = query.offset ?? 0
|
|
60
|
+
const limit = query.limit ?? 20
|
|
61
|
+
const page = matched.slice(offset, offset + limit)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
hits: page.map(m => ({ document: m.document as T, score: m.score })),
|
|
65
|
+
total: matched.length,
|
|
66
|
+
tookMs: Date.now() - start,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async clear(indexName: string): Promise<void> {
|
|
71
|
+
this.indexes.get(indexName)?.clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async close(): Promise<void> {
|
|
75
|
+
this.indexes.clear()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private matchesFilter(
|
|
79
|
+
doc: NixxieSearchDocument,
|
|
80
|
+
filter: Record<string, string | number | boolean>
|
|
81
|
+
): boolean {
|
|
82
|
+
return Object.entries(filter).every(([k, v]) => (doc as any)[k] === v)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private applySort(
|
|
86
|
+
items: { document: NixxieSearchDocument; score: number }[],
|
|
87
|
+
sort: string[]
|
|
88
|
+
): void {
|
|
89
|
+
items.sort((a, b) => {
|
|
90
|
+
for (const spec of sort) {
|
|
91
|
+
const [attr, dir = 'asc'] = spec.split(':')
|
|
92
|
+
const av = (a.document as any)[attr]
|
|
93
|
+
const bv = (b.document as any)[attr]
|
|
94
|
+
if (av === bv) continue
|
|
95
|
+
const cmp = av > bv ? 1 : -1
|
|
96
|
+
return dir === 'desc' ? -cmp : cmp
|
|
97
|
+
}
|
|
98
|
+
return 0
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieSearchDocument,
|
|
3
|
+
NixxieSearchQuery,
|
|
4
|
+
NixxieSearchResults,
|
|
5
|
+
NixxieSearchService,
|
|
6
|
+
} from '@nixxie-cms/core'
|
|
7
|
+
import type { MeilisearchConfig } from './types'
|
|
8
|
+
|
|
9
|
+
/** Meilisearch backend over the REST API (no SDK dependency). */
|
|
10
|
+
export class MeilisearchSearch implements NixxieSearchService {
|
|
11
|
+
private base: string
|
|
12
|
+
private apiKey?: string
|
|
13
|
+
|
|
14
|
+
constructor(config: MeilisearchConfig) {
|
|
15
|
+
this.base = config.url.replace(/\/$/, '')
|
|
16
|
+
this.apiKey = config.apiKey
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async request(path: string, method: string, body?: unknown): Promise<any> {
|
|
20
|
+
const res = await fetch(`${this.base}${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
25
|
+
},
|
|
26
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
27
|
+
})
|
|
28
|
+
if (!res.ok && res.status !== 404) {
|
|
29
|
+
throw new Error(`Meilisearch request failed (${res.status}): ${await res.text()}`)
|
|
30
|
+
}
|
|
31
|
+
return res.status === 404 ? undefined : res.json()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async index(
|
|
35
|
+
indexName: string,
|
|
36
|
+
documents: NixxieSearchDocument | NixxieSearchDocument[]
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const docs = Array.isArray(documents) ? documents : [documents]
|
|
39
|
+
await this.request(`/indexes/${indexName}/documents`, 'POST', docs)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async remove(indexName: string, id: string): Promise<void> {
|
|
43
|
+
await this.request(`/indexes/${indexName}/documents/${encodeURIComponent(id)}`, 'DELETE')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async search<T = NixxieSearchDocument>(
|
|
47
|
+
indexName: string,
|
|
48
|
+
query: NixxieSearchQuery
|
|
49
|
+
): Promise<NixxieSearchResults<T>> {
|
|
50
|
+
const filter = query.filter
|
|
51
|
+
? Object.entries(query.filter).map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
|
|
52
|
+
: undefined
|
|
53
|
+
const res = await this.request(`/indexes/${indexName}/search`, 'POST', {
|
|
54
|
+
q: query.q,
|
|
55
|
+
limit: query.limit ?? 20,
|
|
56
|
+
offset: query.offset ?? 0,
|
|
57
|
+
filter,
|
|
58
|
+
sort: query.sort,
|
|
59
|
+
})
|
|
60
|
+
return {
|
|
61
|
+
hits: (res?.hits ?? []).map((document: T) => ({ document })),
|
|
62
|
+
total: res?.estimatedTotalHits ?? res?.hits?.length ?? 0,
|
|
63
|
+
tookMs: res?.processingTimeMs,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async clear(indexName: string): Promise<void> {
|
|
68
|
+
await this.request(`/indexes/${indexName}/documents`, 'DELETE')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async close(): Promise<void> {}
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieSearchDocument,
|
|
3
|
+
NixxieSearchQuery,
|
|
4
|
+
NixxieSearchResults,
|
|
5
|
+
NixxieSearchService,
|
|
6
|
+
} from '@nixxie-cms/core'
|
|
7
|
+
import type { TypesenseConfig } from './types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Typesense backend over the REST API (no SDK dependency).
|
|
11
|
+
* Collections must already exist; documents are upserted. Typesense has no wildcard `query_by`,
|
|
12
|
+
* so configure `queryBy` to match your collection's searchable fields.
|
|
13
|
+
* Note: `clear()` drops the entire collection (Typesense has no schema-preserving truncate),
|
|
14
|
+
* so recreate the collection before re-indexing.
|
|
15
|
+
*/
|
|
16
|
+
export class TypesenseSearch implements NixxieSearchService {
|
|
17
|
+
private base: string
|
|
18
|
+
private apiKey: string
|
|
19
|
+
private queryBy: string
|
|
20
|
+
|
|
21
|
+
constructor(config: TypesenseConfig) {
|
|
22
|
+
const protocol = config.protocol ?? 'http'
|
|
23
|
+
const port = config.port ?? 8108
|
|
24
|
+
this.base = `${protocol}://${config.host}:${port}`
|
|
25
|
+
this.apiKey = config.apiKey
|
|
26
|
+
this.queryBy = config.queryBy ?? 'title,name,body,content'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private headers(): Record<string, string> {
|
|
30
|
+
return { 'Content-Type': 'application/json', 'X-TYPESENSE-API-KEY': this.apiKey }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async index(
|
|
34
|
+
indexName: string,
|
|
35
|
+
documents: NixxieSearchDocument | NixxieSearchDocument[]
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const docs = Array.isArray(documents) ? documents : [documents]
|
|
38
|
+
const body = docs.map(d => JSON.stringify(d)).join('\n')
|
|
39
|
+
const res = await fetch(
|
|
40
|
+
`${this.base}/collections/${indexName}/documents/import?action=upsert`,
|
|
41
|
+
{ method: 'POST', headers: this.headers(), body }
|
|
42
|
+
)
|
|
43
|
+
if (!res.ok) throw new Error(`Typesense import failed (${res.status}): ${await res.text()}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async remove(indexName: string, id: string): Promise<void> {
|
|
47
|
+
await fetch(`${this.base}/collections/${indexName}/documents/${encodeURIComponent(id)}`, {
|
|
48
|
+
method: 'DELETE',
|
|
49
|
+
headers: this.headers(),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async search<T = NixxieSearchDocument>(
|
|
54
|
+
indexName: string,
|
|
55
|
+
query: NixxieSearchQuery
|
|
56
|
+
): Promise<NixxieSearchResults<T>> {
|
|
57
|
+
// Use Typesense's `offset`/`limit` params (supported since v0.23) rather than `page`/`per_page`,
|
|
58
|
+
// so arbitrary, non-page-aligned offsets return the correct window.
|
|
59
|
+
const params = new URLSearchParams({
|
|
60
|
+
q: query.q,
|
|
61
|
+
query_by: this.queryBy,
|
|
62
|
+
limit: String(query.limit ?? 20),
|
|
63
|
+
offset: String(query.offset ?? 0),
|
|
64
|
+
})
|
|
65
|
+
if (query.filter) {
|
|
66
|
+
params.set(
|
|
67
|
+
'filter_by',
|
|
68
|
+
Object.entries(query.filter)
|
|
69
|
+
.map(([k, v]) => `${k}:=${JSON.stringify(v)}`)
|
|
70
|
+
.join(' && ')
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (query.sort?.length) params.set('sort_by', query.sort.join(','))
|
|
74
|
+
const res = await fetch(`${this.base}/collections/${indexName}/documents/search?${params}`, {
|
|
75
|
+
headers: this.headers(),
|
|
76
|
+
})
|
|
77
|
+
if (!res.ok) throw new Error(`Typesense search failed (${res.status}): ${await res.text()}`)
|
|
78
|
+
const data = await res.json()
|
|
79
|
+
return {
|
|
80
|
+
hits: (data.hits ?? []).map((h: any) => ({ document: h.document as T, score: h.text_match })),
|
|
81
|
+
total: data.found ?? 0,
|
|
82
|
+
tookMs: data.search_time_ms,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async clear(indexName: string): Promise<void> {
|
|
87
|
+
const res = await fetch(`${this.base}/collections/${indexName}`, {
|
|
88
|
+
method: 'DELETE',
|
|
89
|
+
headers: this.headers(),
|
|
90
|
+
})
|
|
91
|
+
// A missing collection (404) is already "cleared"; anything else is a real failure.
|
|
92
|
+
if (!res.ok && res.status !== 404) {
|
|
93
|
+
throw new Error(`Typesense clear failed (${res.status}): ${await res.text()}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async close(): Promise<void> {}
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SearchConfig } from './types'
|
|
2
|
+
import { AlgoliaSearch } from './AlgoliaSearch'
|
|
3
|
+
import { ElasticsearchSearch } from './ElasticsearchSearch'
|
|
4
|
+
import { InMemorySearch } from './InMemorySearch'
|
|
5
|
+
import { MeilisearchSearch } from './MeilisearchSearch'
|
|
6
|
+
import { TypesenseSearch } from './TypesenseSearch'
|
|
7
|
+
|
|
8
|
+
export function createSearch(
|
|
9
|
+
config: SearchConfig
|
|
10
|
+
): InMemorySearch | MeilisearchSearch | TypesenseSearch | AlgoliaSearch | ElasticsearchSearch {
|
|
11
|
+
switch (config.driver) {
|
|
12
|
+
case 'memory':
|
|
13
|
+
return new InMemorySearch()
|
|
14
|
+
case 'meilisearch':
|
|
15
|
+
return new MeilisearchSearch(config)
|
|
16
|
+
case 'typesense':
|
|
17
|
+
return new TypesenseSearch(config)
|
|
18
|
+
case 'algolia':
|
|
19
|
+
return new AlgoliaSearch(config)
|
|
20
|
+
case 'elasticsearch':
|
|
21
|
+
return new ElasticsearchSearch(config)
|
|
22
|
+
default: {
|
|
23
|
+
const exhaustive: never = config
|
|
24
|
+
throw new Error(`Unknown search driver: ${(exhaustive as any).driver}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { InMemorySearch, MeilisearchSearch, TypesenseSearch, AlgoliaSearch, ElasticsearchSearch }
|
|
30
|
+
export type {
|
|
31
|
+
SearchConfig,
|
|
32
|
+
InMemorySearchConfig,
|
|
33
|
+
MeilisearchConfig,
|
|
34
|
+
TypesenseConfig,
|
|
35
|
+
AlgoliaConfig,
|
|
36
|
+
ElasticsearchConfig,
|
|
37
|
+
} from './types'
|
|
38
|
+
export type {
|
|
39
|
+
NixxieSearchService,
|
|
40
|
+
NixxieSearchDocument,
|
|
41
|
+
NixxieSearchQuery,
|
|
42
|
+
NixxieSearchHit,
|
|
43
|
+
NixxieSearchResults,
|
|
44
|
+
} from '@nixxie-cms/core'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NixxieSearchDocument,
|
|
3
|
+
NixxieSearchHit,
|
|
4
|
+
NixxieSearchQuery,
|
|
5
|
+
NixxieSearchResults,
|
|
6
|
+
NixxieSearchService,
|
|
7
|
+
} from '@nixxie-cms/core'
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
NixxieSearchDocument,
|
|
11
|
+
NixxieSearchHit,
|
|
12
|
+
NixxieSearchQuery,
|
|
13
|
+
NixxieSearchResults,
|
|
14
|
+
NixxieSearchService,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type InMemorySearchConfig = {
|
|
18
|
+
driver: 'memory'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MeilisearchConfig = {
|
|
22
|
+
driver: 'meilisearch'
|
|
23
|
+
/** Meilisearch host URL, e.g. http://localhost:7700 */
|
|
24
|
+
url: string
|
|
25
|
+
/** API key (master or scoped). */
|
|
26
|
+
apiKey?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TypesenseConfig = {
|
|
30
|
+
driver: 'typesense'
|
|
31
|
+
/** Host (without protocol), e.g. localhost */
|
|
32
|
+
host: string
|
|
33
|
+
/** Port. Default: 8108 */
|
|
34
|
+
port?: number
|
|
35
|
+
/** 'http' or 'https'. Default: 'http' */
|
|
36
|
+
protocol?: 'http' | 'https'
|
|
37
|
+
/** API key. */
|
|
38
|
+
apiKey: string
|
|
39
|
+
/**
|
|
40
|
+
* Comma-separated fields Typesense should search against (it has no wildcard query_by).
|
|
41
|
+
* Default: 'title,name,body,content'
|
|
42
|
+
*/
|
|
43
|
+
queryBy?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AlgoliaConfig = {
|
|
47
|
+
driver: 'algolia'
|
|
48
|
+
/** Algolia application id. */
|
|
49
|
+
appId: string
|
|
50
|
+
/** Admin API key (write access). */
|
|
51
|
+
apiKey: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ElasticsearchConfig = {
|
|
55
|
+
driver: 'elasticsearch'
|
|
56
|
+
/** Node URL, e.g. http://localhost:9200 */
|
|
57
|
+
node: string
|
|
58
|
+
/** Optional API key for auth. */
|
|
59
|
+
apiKey?: string
|
|
60
|
+
/** Optional basic-auth username. */
|
|
61
|
+
username?: string
|
|
62
|
+
/** Optional basic-auth password. */
|
|
63
|
+
password?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type SearchConfig =
|
|
67
|
+
| InMemorySearchConfig
|
|
68
|
+
| MeilisearchConfig
|
|
69
|
+
| TypesenseConfig
|
|
70
|
+
| AlgoliaConfig
|
|
71
|
+
| ElasticsearchConfig
|