@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.
@@ -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