@basaltkit/search 1.0.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/index.d.ts +162 -0
- package/dist/index.js +250 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Machize Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# @basaltkit/search
|
|
2
|
+
|
|
3
|
+
Full-text search for Basalt: indexes and searches documents **per tenant**, with a typed API and an interchangeable driver — **in-memory** for development/testing and **Meilisearch** for production. You need this module when you want to give users a fast, relevant search box over their data (notes, projects, customers…).
|
|
4
|
+
|
|
5
|
+
## What this module solves
|
|
6
|
+
|
|
7
|
+
Searching well is more than a `WHERE ... LIKE '%text%'`: you need **relevance** (the best results first), **prefix** matching, and **tenant isolation** (customer A never sees customer B's data). This module gives you that with:
|
|
8
|
+
|
|
9
|
+
- **Typed indexes** — declare once which fields are searchable and filterable.
|
|
10
|
+
- **Guaranteed tenant isolation** — every search is scoped to `tenantId`; a result never "leaks" between tenants.
|
|
11
|
+
- **Interchangeable driver** — `MemorySearchDriver` (no services, for dev/test) and `MeilisearchDriver` (production). Your code doesn't change when you switch.
|
|
12
|
+
- **Automatic indexing** — hooks into domain events (created/updated/deleted) and the index keeps itself up to date.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @basaltkit/search
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Depends only on `@basaltkit/core`. `MemorySearchDriver` works with nothing installed; for production, point `MeilisearchDriver` at a Meilisearch server.
|
|
21
|
+
|
|
22
|
+
## Get started in 5 minutes
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createApp } from '@basaltkit/core'
|
|
26
|
+
import { searchPlugin, SEARCH, defineIndex } from '@basaltkit/search'
|
|
27
|
+
|
|
28
|
+
const app = await createApp({
|
|
29
|
+
plugins: [
|
|
30
|
+
searchPlugin({
|
|
31
|
+
indexes: [defineIndex({ name: 'notes', fields: ['title', 'body'], filterable: ['folder'] })],
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
}).boot()
|
|
35
|
+
|
|
36
|
+
const search = app.container.get(SEARCH)
|
|
37
|
+
|
|
38
|
+
// index (the document carries the tenantId)
|
|
39
|
+
await search.index('notes', { id: '1', tenantId: 'acme', title: 'Hello world', body: 'first note', folder: 'inbox' })
|
|
40
|
+
|
|
41
|
+
// search (the tenant comes from the request context, or you pass it explicitly)
|
|
42
|
+
const result = await search.search('notes', 'hello', { tenantId: 'acme', filters: { folder: 'inbox' } })
|
|
43
|
+
console.log(result.hits) // [{ id: '1', score, document }]
|
|
44
|
+
console.log(result.total)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Automatic indexing (hooks → index)
|
|
48
|
+
|
|
49
|
+
Instead of indexing by hand everywhere, wire domain events to the index — and it keeps itself up to date:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { searchPlugin, defineIndex, syncRule } from '@basaltkit/search'
|
|
53
|
+
|
|
54
|
+
searchPlugin({
|
|
55
|
+
indexes: [defineIndex({ name: 'notes', fields: ['title', 'body'] })],
|
|
56
|
+
sync: [
|
|
57
|
+
syncRule({
|
|
58
|
+
hook: 'note:created', // or note:updated
|
|
59
|
+
index: 'notes',
|
|
60
|
+
document: (p) => ({ id: p.note.id, tenantId: p.tenantId, title: p.note.title, body: p.note.body }),
|
|
61
|
+
}),
|
|
62
|
+
syncRule({
|
|
63
|
+
hook: 'note:deleted',
|
|
64
|
+
index: 'notes',
|
|
65
|
+
remove: (p) => ({ tenantId: p.tenantId, id: p.noteId }),
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`syncRule` type-checks against the hook's payload. Return `null` from `document`/`remove` to skip an event.
|
|
72
|
+
|
|
73
|
+
## How relevance works (in-memory driver)
|
|
74
|
+
|
|
75
|
+
`MemorySearchDriver` tokenizes the searchable fields and scores by **term frequency** with **prefix** matching (`qui` matches `quick`). It requires **all** query terms to be present (AND semantics), sorts by score, and only searches the fields declared in `fields`. It's deterministic and good enough for development; in production Meilisearch provides real relevance (typo-tolerance, stemming, etc.).
|
|
76
|
+
|
|
77
|
+
## Production with Meilisearch
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { searchPlugin, MeilisearchDriver, defineIndex } from '@basaltkit/search'
|
|
81
|
+
|
|
82
|
+
searchPlugin({
|
|
83
|
+
driver: new MeilisearchDriver({ host: 'http://localhost:7700', apiKey: process.env.MEILI_KEY }),
|
|
84
|
+
indexes: [defineIndex({ name: 'notes', fields: ['title', 'body'], filterable: ['folder'] })],
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The driver talks directly to Meilisearch's REST API (no SDK). Each document gets a composite primary key (`_pk`), so ids never collide across tenants; and **every search is filtered by `tenantId`**, guaranteeing isolation. `defineIndex(...).filterable` is automatically declared as a filterable attribute in Meilisearch.
|
|
89
|
+
|
|
90
|
+
## API reference
|
|
91
|
+
|
|
92
|
+
### `searchPlugin(options?)`
|
|
93
|
+
|
|
94
|
+
| Option | Type | Default | Description |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `driver` | `SearchDriver` | `MemorySearchDriver` | Search backend. |
|
|
97
|
+
| `indexes` | `IndexDefinition[]` | `[]` | Indexes to register on startup. |
|
|
98
|
+
| `sync` | `SyncRule[]` | `[]` | Hook → index rules (use `syncRule(...)`). |
|
|
99
|
+
|
|
100
|
+
Registers the `SEARCH` token (`Search`).
|
|
101
|
+
|
|
102
|
+
### `class Search`
|
|
103
|
+
|
|
104
|
+
| Method | Description |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `index(indexName, document)` | Indexes/updates a document (carries `id` and `tenantId`). |
|
|
107
|
+
| `bulk(indexName, documents)` | Indexes several. |
|
|
108
|
+
| `remove(indexName, id, tenantId?)` | Removes a document (tenant from context if omitted). |
|
|
109
|
+
| `search(indexName, q, options?)` | Searches. `options`: `tenantId?`, `filters?`, `limit?`, `offset?`. |
|
|
110
|
+
|
|
111
|
+
Without an explicit `tenantId`, `search`/`remove` use `ctx().tenant.id`; if there's no tenant, they throw `TenantRequiredError`.
|
|
112
|
+
|
|
113
|
+
### `defineIndex({ name, fields, filterable? })`
|
|
114
|
+
|
|
115
|
+
Declares an index: `fields` are searchable (full-text), `filterable` are usable in `filters` (`tenantId` is always filterable).
|
|
116
|
+
|
|
117
|
+
### Drivers
|
|
118
|
+
|
|
119
|
+
- `MemorySearchDriver` — in-process, dev/test.
|
|
120
|
+
- `MeilisearchDriver({ host, apiKey?, fetch? })` — production; `fetch` is injectable for tests.
|
|
121
|
+
|
|
122
|
+
## How it connects to other modules
|
|
123
|
+
|
|
124
|
+
- **`@basaltkit/core`** — `createApp`, tokens, hooks (which automatic sync consumes), and the context `tenantId` comes from.
|
|
125
|
+
- **`@basaltkit/tenancy`** — places `tenant` in the context; with it active, `search.search('notes', q)` already knows the tenant.
|
|
126
|
+
- **`@basaltkit/events`** — emits the domain events that feed `sync`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as _basaltkit_core from '@basaltkit/core';
|
|
2
|
+
import { BasaltError, BasaltHooks } from '@basaltkit/core';
|
|
3
|
+
|
|
4
|
+
/** A document to index. Always carries an `id` and the owning `tenantId`. */
|
|
5
|
+
interface SearchDocument {
|
|
6
|
+
id: string;
|
|
7
|
+
tenantId: string;
|
|
8
|
+
[field: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
/** Declares an index: which fields are searchable (full-text) and filterable. */
|
|
11
|
+
interface IndexDefinition {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Text fields matched by the query. */
|
|
14
|
+
fields: string[];
|
|
15
|
+
/** Fields usable in `filters` (exact match). `tenantId` is always filterable. */
|
|
16
|
+
filterable?: string[];
|
|
17
|
+
}
|
|
18
|
+
declare function defineIndex(definition: IndexDefinition): IndexDefinition;
|
|
19
|
+
interface SearchQuery {
|
|
20
|
+
tenantId: string;
|
|
21
|
+
q: string;
|
|
22
|
+
/** Exact-match filters. A value array means "any of". */
|
|
23
|
+
filters?: Record<string, unknown>;
|
|
24
|
+
limit?: number;
|
|
25
|
+
offset?: number;
|
|
26
|
+
}
|
|
27
|
+
interface SearchHit {
|
|
28
|
+
id: string;
|
|
29
|
+
score: number;
|
|
30
|
+
document: SearchDocument;
|
|
31
|
+
}
|
|
32
|
+
interface SearchResult {
|
|
33
|
+
hits: SearchHit[];
|
|
34
|
+
/** Total matches (before limit/offset). */
|
|
35
|
+
total: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Search backend contract. The core talks to this; drivers talk to the engine.
|
|
39
|
+
* Every operation is tenant-scoped: `search` must only ever return documents
|
|
40
|
+
* whose `tenantId` matches the query.
|
|
41
|
+
*/
|
|
42
|
+
interface SearchDriver {
|
|
43
|
+
/** Optional: called once per index at boot with its config. */
|
|
44
|
+
register?(index: IndexDefinition): Promise<void>;
|
|
45
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
46
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
47
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
48
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
49
|
+
/** Drops every document in the index (used by tests). */
|
|
50
|
+
clear(indexName: string): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* In-process full-text driver for dev and tests — no external engine. Scores by
|
|
55
|
+
* term frequency with prefix matching, requires every query term to match (AND
|
|
56
|
+
* semantics), scopes results to the query's tenant, and supports exact-match
|
|
57
|
+
* filters. Good enough for development; swap in {@link MeilisearchDriver} for
|
|
58
|
+
* production-scale relevance.
|
|
59
|
+
*/
|
|
60
|
+
declare class MemorySearchDriver implements SearchDriver {
|
|
61
|
+
private readonly documents;
|
|
62
|
+
private readonly configs;
|
|
63
|
+
register(index: IndexDefinition): Promise<void>;
|
|
64
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
65
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
66
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
67
|
+
clear(indexName: string): Promise<void>;
|
|
68
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
69
|
+
private score;
|
|
70
|
+
private passesFilters;
|
|
71
|
+
private searchableFields;
|
|
72
|
+
private store;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Search was asked for a tenant it couldn't determine. */
|
|
76
|
+
declare class TenantRequiredError extends BasaltError {
|
|
77
|
+
readonly status = 400;
|
|
78
|
+
constructor();
|
|
79
|
+
}
|
|
80
|
+
interface SearchOptions {
|
|
81
|
+
/** Defaults to the current tenant (`ctx().tenant.id`). */
|
|
82
|
+
tenantId?: string;
|
|
83
|
+
filters?: Record<string, unknown>;
|
|
84
|
+
limit?: number;
|
|
85
|
+
offset?: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Tenant-scoped full-text search. Indexing takes the tenant from the document;
|
|
89
|
+
* querying takes it from `options.tenantId` or the current request context.
|
|
90
|
+
*/
|
|
91
|
+
declare class Search {
|
|
92
|
+
private readonly driver;
|
|
93
|
+
constructor(options?: {
|
|
94
|
+
driver?: SearchDriver;
|
|
95
|
+
});
|
|
96
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
97
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
98
|
+
remove(indexName: string, id: string, tenantId?: string): Promise<void>;
|
|
99
|
+
search(indexName: string, q: string, options?: SearchOptions): Promise<SearchResult>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
declare class MeilisearchError extends BasaltError {
|
|
103
|
+
readonly httpStatus: number;
|
|
104
|
+
constructor(httpStatus: number, message: string);
|
|
105
|
+
}
|
|
106
|
+
interface MeilisearchDriverOptions {
|
|
107
|
+
host: string;
|
|
108
|
+
apiKey?: string;
|
|
109
|
+
/** Injected fetch (tests). Default: global fetch. */
|
|
110
|
+
fetch?: typeof fetch;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Meilisearch driver targeting the REST API directly (no SDK). Documents get a
|
|
114
|
+
* compound `_pk` so ids never collide across tenants, and every search is
|
|
115
|
+
* constrained with a `tenantId` filter so results never leak between tenants.
|
|
116
|
+
*/
|
|
117
|
+
declare class MeilisearchDriver implements SearchDriver {
|
|
118
|
+
private readonly options;
|
|
119
|
+
private readonly fetch;
|
|
120
|
+
constructor(options: MeilisearchDriverOptions);
|
|
121
|
+
register(index: IndexDefinition): Promise<void>;
|
|
122
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
123
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
124
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
125
|
+
clear(indexName: string): Promise<void>;
|
|
126
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
127
|
+
private buildFilter;
|
|
128
|
+
private request;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
declare const SEARCH: _basaltkit_core.Token<Search>;
|
|
132
|
+
/**
|
|
133
|
+
* Keeps an index in sync with domain events: on the given hook, either upsert a
|
|
134
|
+
* document or remove one. Wire it once and your search index maintains itself.
|
|
135
|
+
*
|
|
136
|
+
* syncRule({ hook: 'note:created', index: 'notes', document: (p) => ({
|
|
137
|
+
* id: p.note.id, tenantId: p.tenantId, title: p.note.title, body: p.note.body,
|
|
138
|
+
* })})
|
|
139
|
+
*/
|
|
140
|
+
interface SyncRule<K extends keyof BasaltHooks & string = keyof BasaltHooks & string> {
|
|
141
|
+
hook: K;
|
|
142
|
+
index: string;
|
|
143
|
+
/** Build the document to upsert. Return null to skip. */
|
|
144
|
+
document?: (payload: BasaltHooks[K]) => SearchDocument | null;
|
|
145
|
+
/** Or the identifiers to remove. Return null to skip. */
|
|
146
|
+
remove?: (payload: BasaltHooks[K]) => {
|
|
147
|
+
tenantId: string;
|
|
148
|
+
id: string;
|
|
149
|
+
} | null;
|
|
150
|
+
}
|
|
151
|
+
/** Type-checks a sync rule against its hook, then erases the generic. */
|
|
152
|
+
declare function syncRule<K extends keyof BasaltHooks & string>(rule: SyncRule<K>): SyncRule;
|
|
153
|
+
interface SearchPluginOptions {
|
|
154
|
+
driver?: SearchDriver;
|
|
155
|
+
/** Indexes to register with the driver at boot. */
|
|
156
|
+
indexes?: IndexDefinition[];
|
|
157
|
+
/** Rules keeping indexes in sync with domain hooks. */
|
|
158
|
+
sync?: SyncRule[];
|
|
159
|
+
}
|
|
160
|
+
declare function searchPlugin(options?: SearchPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
|
|
161
|
+
|
|
162
|
+
export { type IndexDefinition, MeilisearchDriver, type MeilisearchDriverOptions, MeilisearchError, MemorySearchDriver, SEARCH, Search, type SearchDocument, type SearchDriver, type SearchHit, type SearchOptions, type SearchPluginOptions, type SearchQuery, type SearchResult, type SyncRule, TenantRequiredError, defineIndex, searchPlugin, syncRule };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
function defineIndex(definition) {
|
|
3
|
+
return definition;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// src/memory.ts
|
|
7
|
+
var tokenize = (text) => text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
8
|
+
var docKey = (tenantId, id) => `${tenantId}\0${id}`;
|
|
9
|
+
var MemorySearchDriver = class {
|
|
10
|
+
documents = /* @__PURE__ */ new Map();
|
|
11
|
+
configs = /* @__PURE__ */ new Map();
|
|
12
|
+
async register(index) {
|
|
13
|
+
this.configs.set(index.name, index);
|
|
14
|
+
if (!this.documents.has(index.name)) this.documents.set(index.name, /* @__PURE__ */ new Map());
|
|
15
|
+
}
|
|
16
|
+
async index(indexName, document) {
|
|
17
|
+
this.store(indexName).set(docKey(document.tenantId, document.id), document);
|
|
18
|
+
}
|
|
19
|
+
async bulk(indexName, documents) {
|
|
20
|
+
for (const document of documents) await this.index(indexName, document);
|
|
21
|
+
}
|
|
22
|
+
async remove(indexName, tenantId, id) {
|
|
23
|
+
this.store(indexName).delete(docKey(tenantId, id));
|
|
24
|
+
}
|
|
25
|
+
async clear(indexName) {
|
|
26
|
+
this.documents.set(indexName, /* @__PURE__ */ new Map());
|
|
27
|
+
}
|
|
28
|
+
async search(indexName, query) {
|
|
29
|
+
const fields = this.searchableFields(indexName);
|
|
30
|
+
const terms = tokenize(query.q);
|
|
31
|
+
const scored = [];
|
|
32
|
+
for (const document of this.store(indexName).values()) {
|
|
33
|
+
if (document.tenantId !== query.tenantId) continue;
|
|
34
|
+
if (!this.passesFilters(document, query.filters)) continue;
|
|
35
|
+
const score = this.score(document, fields, terms);
|
|
36
|
+
if (score < 0) continue;
|
|
37
|
+
scored.push({ id: document.id, score, document });
|
|
38
|
+
}
|
|
39
|
+
scored.sort((a, b) => b.score - a.score);
|
|
40
|
+
const offset = query.offset ?? 0;
|
|
41
|
+
const limit = query.limit ?? 20;
|
|
42
|
+
return { hits: scored.slice(offset, offset + limit), total: scored.length };
|
|
43
|
+
}
|
|
44
|
+
score(document, fields, terms) {
|
|
45
|
+
if (terms.length === 0) return 0;
|
|
46
|
+
const tokens = tokenize(fields.map((field) => String(document[field] ?? "")).join(" "));
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const term of terms) {
|
|
49
|
+
let termScore = 0;
|
|
50
|
+
for (const token of tokens) {
|
|
51
|
+
if (token === term) termScore += 2;
|
|
52
|
+
else if (token.startsWith(term)) termScore += 1;
|
|
53
|
+
}
|
|
54
|
+
if (termScore === 0) return -1;
|
|
55
|
+
total += termScore;
|
|
56
|
+
}
|
|
57
|
+
return total;
|
|
58
|
+
}
|
|
59
|
+
passesFilters(document, filters) {
|
|
60
|
+
if (!filters) return true;
|
|
61
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
62
|
+
const actual = document[field];
|
|
63
|
+
if (Array.isArray(value) ? !value.includes(actual) : actual !== value) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
searchableFields(indexName) {
|
|
68
|
+
const config = this.configs.get(indexName);
|
|
69
|
+
if (config) return config.fields;
|
|
70
|
+
const sample = this.store(indexName).values().next().value;
|
|
71
|
+
if (!sample) return [];
|
|
72
|
+
return Object.keys(sample).filter((k) => k !== "id" && k !== "tenantId" && typeof sample[k] === "string");
|
|
73
|
+
}
|
|
74
|
+
store(indexName) {
|
|
75
|
+
let store = this.documents.get(indexName);
|
|
76
|
+
if (!store) {
|
|
77
|
+
store = /* @__PURE__ */ new Map();
|
|
78
|
+
this.documents.set(indexName, store);
|
|
79
|
+
}
|
|
80
|
+
return store;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/search.ts
|
|
85
|
+
import { BasaltError, tryCtx } from "@basaltkit/core";
|
|
86
|
+
var TenantRequiredError = class extends BasaltError {
|
|
87
|
+
status = 400;
|
|
88
|
+
constructor() {
|
|
89
|
+
super("SEARCH_TENANT_REQUIRED", "A tenant is required \u2014 pass tenantId or run inside a tenant context.");
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var currentTenant = (explicit) => {
|
|
93
|
+
const id = explicit ?? tryCtx()?.["tenant"]?.id;
|
|
94
|
+
if (!id) throw new TenantRequiredError();
|
|
95
|
+
return id;
|
|
96
|
+
};
|
|
97
|
+
var Search = class {
|
|
98
|
+
driver;
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
this.driver = options.driver ?? new MemorySearchDriver();
|
|
101
|
+
}
|
|
102
|
+
index(indexName, document) {
|
|
103
|
+
return this.driver.index(indexName, document);
|
|
104
|
+
}
|
|
105
|
+
bulk(indexName, documents) {
|
|
106
|
+
return this.driver.bulk(indexName, documents);
|
|
107
|
+
}
|
|
108
|
+
async remove(indexName, id, tenantId) {
|
|
109
|
+
return this.driver.remove(indexName, currentTenant(tenantId), id);
|
|
110
|
+
}
|
|
111
|
+
async search(indexName, q, options = {}) {
|
|
112
|
+
return this.driver.search(indexName, {
|
|
113
|
+
tenantId: currentTenant(options.tenantId),
|
|
114
|
+
q,
|
|
115
|
+
...options.filters ? { filters: options.filters } : {},
|
|
116
|
+
...options.limit !== void 0 ? { limit: options.limit } : {},
|
|
117
|
+
...options.offset !== void 0 ? { offset: options.offset } : {}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/drivers/meilisearch.ts
|
|
123
|
+
import { BasaltError as BasaltError2 } from "@basaltkit/core";
|
|
124
|
+
var MeilisearchError = class extends BasaltError2 {
|
|
125
|
+
constructor(httpStatus, message) {
|
|
126
|
+
super("SEARCH_ENGINE_ERROR", `Meilisearch request failed (${httpStatus}): ${message}`);
|
|
127
|
+
this.httpStatus = httpStatus;
|
|
128
|
+
}
|
|
129
|
+
httpStatus;
|
|
130
|
+
};
|
|
131
|
+
var primaryKey = (tenantId, id) => Buffer.from(`${tenantId}::${id}`).toString("base64url");
|
|
132
|
+
var quote = (value) => JSON.stringify(value);
|
|
133
|
+
var MeilisearchDriver = class {
|
|
134
|
+
constructor(options) {
|
|
135
|
+
this.options = options;
|
|
136
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
137
|
+
}
|
|
138
|
+
options;
|
|
139
|
+
fetch;
|
|
140
|
+
async register(index) {
|
|
141
|
+
try {
|
|
142
|
+
await this.request("POST", "/indexes", { uid: index.name, primaryKey: "_pk" });
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
await this.request("PATCH", `/indexes/${index.name}/settings`, {
|
|
146
|
+
searchableAttributes: index.fields,
|
|
147
|
+
filterableAttributes: ["tenantId", ...index.filterable ?? []]
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async index(indexName, document) {
|
|
151
|
+
await this.bulk(indexName, [document]);
|
|
152
|
+
}
|
|
153
|
+
async bulk(indexName, documents) {
|
|
154
|
+
const withPk = documents.map((d) => ({ ...d, _pk: primaryKey(d.tenantId, d.id) }));
|
|
155
|
+
await this.request("PUT", `/indexes/${indexName}/documents`, withPk);
|
|
156
|
+
}
|
|
157
|
+
async remove(indexName, tenantId, id) {
|
|
158
|
+
await this.request("DELETE", `/indexes/${indexName}/documents/${primaryKey(tenantId, id)}`);
|
|
159
|
+
}
|
|
160
|
+
async clear(indexName) {
|
|
161
|
+
await this.request("DELETE", `/indexes/${indexName}/documents`);
|
|
162
|
+
}
|
|
163
|
+
async search(indexName, query) {
|
|
164
|
+
const result = await this.request("POST", `/indexes/${indexName}/search`, {
|
|
165
|
+
q: query.q,
|
|
166
|
+
filter: this.buildFilter(query.tenantId, query.filters),
|
|
167
|
+
limit: query.limit ?? 20,
|
|
168
|
+
offset: query.offset ?? 0,
|
|
169
|
+
showRankingScore: true
|
|
170
|
+
});
|
|
171
|
+
const hits = (result.hits ?? []).map((hit) => {
|
|
172
|
+
const { _pk, _rankingScore, ...document } = hit;
|
|
173
|
+
void _pk;
|
|
174
|
+
return {
|
|
175
|
+
id: String(document["id"]),
|
|
176
|
+
score: typeof _rankingScore === "number" ? _rankingScore : 0,
|
|
177
|
+
document
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
return { hits, total: result.estimatedTotalHits ?? hits.length };
|
|
181
|
+
}
|
|
182
|
+
buildFilter(tenantId, filters) {
|
|
183
|
+
const parts = [`tenantId = ${quote(tenantId)}`];
|
|
184
|
+
for (const [field, value] of Object.entries(filters ?? {})) {
|
|
185
|
+
parts.push(Array.isArray(value) ? `${field} IN [${value.map(quote).join(", ")}]` : `${field} = ${quote(value)}`);
|
|
186
|
+
}
|
|
187
|
+
return parts.join(" AND ");
|
|
188
|
+
}
|
|
189
|
+
async request(method, path, body) {
|
|
190
|
+
const response = await this.fetch(`${this.options.host}${path}`, {
|
|
191
|
+
method,
|
|
192
|
+
headers: {
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
...this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}
|
|
195
|
+
},
|
|
196
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
197
|
+
});
|
|
198
|
+
const text = await response.text();
|
|
199
|
+
const json = text ? JSON.parse(text) : {};
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const message = json.message ?? text ?? "unknown error";
|
|
202
|
+
throw new MeilisearchError(response.status, message);
|
|
203
|
+
}
|
|
204
|
+
return json;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/plugin.ts
|
|
209
|
+
import { createToken, definePlugin } from "@basaltkit/core";
|
|
210
|
+
var SEARCH = createToken("search");
|
|
211
|
+
function syncRule(rule) {
|
|
212
|
+
return rule;
|
|
213
|
+
}
|
|
214
|
+
function searchPlugin(options = {}) {
|
|
215
|
+
const driver = options.driver ?? new MemorySearchDriver();
|
|
216
|
+
return definePlugin({
|
|
217
|
+
name: "basalt:search",
|
|
218
|
+
register({ container }) {
|
|
219
|
+
container.singleton(SEARCH, () => new Search({ driver }));
|
|
220
|
+
},
|
|
221
|
+
async boot({ container, hooks }) {
|
|
222
|
+
const search = container.get(SEARCH);
|
|
223
|
+
if (driver.register) {
|
|
224
|
+
for (const index of options.indexes ?? []) await driver.register(index);
|
|
225
|
+
}
|
|
226
|
+
for (const rule of options.sync ?? []) {
|
|
227
|
+
hooks.on(rule.hook, async (payload) => {
|
|
228
|
+
if (rule.document) {
|
|
229
|
+
const document = rule.document(payload);
|
|
230
|
+
if (document) await search.index(rule.index, document);
|
|
231
|
+
} else if (rule.remove) {
|
|
232
|
+
const target = rule.remove(payload);
|
|
233
|
+
if (target) await search.remove(rule.index, target.id, target.tenantId);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export {
|
|
241
|
+
MeilisearchDriver,
|
|
242
|
+
MeilisearchError,
|
|
243
|
+
MemorySearchDriver,
|
|
244
|
+
SEARCH,
|
|
245
|
+
Search,
|
|
246
|
+
TenantRequiredError,
|
|
247
|
+
defineIndex,
|
|
248
|
+
searchPlugin,
|
|
249
|
+
syncRule
|
|
250
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@basaltkit/search",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Full-text search for Basalt: tenant-scoped indexing and querying with a pluggable driver (in-memory for dev/test, Meilisearch for production) and automatic sync from domain events.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@basaltkit/core": "^1.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.15.0",
|
|
21
|
+
"tsup": "^8.4.0",
|
|
22
|
+
"typescript": "^5.8.0",
|
|
23
|
+
"vitest": "^3.1.0",
|
|
24
|
+
"@basaltkit/tsconfig": "^0.24.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/Zebedeu/basalt.git",
|
|
32
|
+
"directory": "packages/search"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/search#readme",
|
|
35
|
+
"bugs": "https://github.com/Zebedeu/basalt/issues",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"basalt",
|
|
38
|
+
"typescript",
|
|
39
|
+
"saas",
|
|
40
|
+
"search",
|
|
41
|
+
"full-text",
|
|
42
|
+
"meilisearch"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|