@basaltkit/search 1.2.0 → 1.3.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/README.md +6 -0
- package/dist/drivers/meilisearch.d.ts +36 -0
- package/dist/drivers/meilisearch.js +127 -0
- package/dist/index.d.ts +5 -172
- package/dist/index.js +5 -269
- package/dist/memory.d.ts +22 -0
- package/dist/memory.js +0 -0
- package/dist/plugin.d.ts +40 -0
- package/dist/plugin.js +48 -0
- package/dist/search.d.ts +28 -0
- package/dist/search.js +43 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +3 -0
- package/package.json +9 -10
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://basaltkit-docs.pages.dev">
|
|
3
|
+
<img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# @basaltkit/search
|
|
2
8
|
|
|
3
9
|
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…).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
import type { IndexDefinition, SearchDocument, SearchDriver, SearchQuery, SearchResult } from '../types.js';
|
|
3
|
+
export declare class MeilisearchError extends BasaltError {
|
|
4
|
+
readonly httpStatus: number;
|
|
5
|
+
constructor(httpStatus: number, message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class SearchFilterFieldError extends BasaltError {
|
|
8
|
+
constructor(field: string);
|
|
9
|
+
}
|
|
10
|
+
export declare class SearchIndexNameError extends BasaltError {
|
|
11
|
+
constructor(name: string);
|
|
12
|
+
}
|
|
13
|
+
export interface MeilisearchDriverOptions {
|
|
14
|
+
host: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
/** Injected fetch (tests). Default: global fetch. */
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Meilisearch driver targeting the REST API directly (no SDK). Documents get a
|
|
21
|
+
* compound `_pk` so ids never collide across tenants, and every search is
|
|
22
|
+
* constrained with a `tenantId` filter so results never leak between tenants.
|
|
23
|
+
*/
|
|
24
|
+
export declare class MeilisearchDriver implements SearchDriver {
|
|
25
|
+
private readonly options;
|
|
26
|
+
private readonly fetch;
|
|
27
|
+
constructor(options: MeilisearchDriverOptions);
|
|
28
|
+
register(index: IndexDefinition): Promise<void>;
|
|
29
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
30
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
31
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
32
|
+
clear(indexName: string): Promise<void>;
|
|
33
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
34
|
+
private buildFilter;
|
|
35
|
+
private request;
|
|
36
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
export class MeilisearchError extends BasaltError {
|
|
3
|
+
httpStatus;
|
|
4
|
+
constructor(httpStatus, message) {
|
|
5
|
+
super('SEARCH_ENGINE_ERROR', `Meilisearch request failed (${httpStatus}): ${message}`);
|
|
6
|
+
this.httpStatus = httpStatus;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class SearchFilterFieldError extends BasaltError {
|
|
10
|
+
constructor(field) {
|
|
11
|
+
super('SEARCH_INVALID_FILTER_FIELD', `Invalid filter field name: ${JSON.stringify(field)}.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class SearchIndexNameError extends BasaltError {
|
|
15
|
+
constructor(name) {
|
|
16
|
+
super('SEARCH_INVALID_INDEX_NAME', `Invalid index name: ${JSON.stringify(name)}.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// The index name is interpolated into Meilisearch REST URL paths
|
|
20
|
+
// (`/indexes/${name}/...`). Meilisearch index uids only allow letters, digits,
|
|
21
|
+
// hyphens and underscores, so anything else is both invalid upstream and a way
|
|
22
|
+
// to break out of the path — reject it at the boundary. Config-time identifier,
|
|
23
|
+
// not request input, but validated defensively.
|
|
24
|
+
const SAFE_INDEX_NAME = /^[A-Za-z0-9_-]+$/;
|
|
25
|
+
/** Throw on an index name that isn't a safe Meilisearch uid / path segment. */
|
|
26
|
+
const assertValidIndexName = (name) => {
|
|
27
|
+
if (!SAFE_INDEX_NAME.test(name))
|
|
28
|
+
throw new SearchIndexNameError(name);
|
|
29
|
+
return name;
|
|
30
|
+
};
|
|
31
|
+
// A filter field is interpolated into Meilisearch's filter DSL, so it must be a
|
|
32
|
+
// bare identifier (optionally dotted). Rejecting anything else stops a crafted
|
|
33
|
+
// field name from injecting operators (` OR `, `=`, quotes) to escape the
|
|
34
|
+
// mandatory `tenantId` scope and read another tenant's documents.
|
|
35
|
+
const SAFE_FILTER_FIELD = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
36
|
+
/** Stable Meilisearch primary key from (tenant, id) — always a valid doc id. */
|
|
37
|
+
const primaryKey = (tenantId, id) => Buffer.from(`${tenantId}::${id}`).toString('base64url');
|
|
38
|
+
const quote = (value) => JSON.stringify(value);
|
|
39
|
+
/**
|
|
40
|
+
* Meilisearch driver targeting the REST API directly (no SDK). Documents get a
|
|
41
|
+
* compound `_pk` so ids never collide across tenants, and every search is
|
|
42
|
+
* constrained with a `tenantId` filter so results never leak between tenants.
|
|
43
|
+
*/
|
|
44
|
+
export class MeilisearchDriver {
|
|
45
|
+
options;
|
|
46
|
+
fetch;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.options = options;
|
|
49
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
50
|
+
}
|
|
51
|
+
async register(index) {
|
|
52
|
+
assertValidIndexName(index.name);
|
|
53
|
+
// Create the index (ignore "already exists"), then declare attributes.
|
|
54
|
+
try {
|
|
55
|
+
await this.request('POST', '/indexes', { uid: index.name, primaryKey: '_pk' });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// index_already_exists and similar are fine to ignore on register.
|
|
59
|
+
}
|
|
60
|
+
await this.request('PATCH', `/indexes/${index.name}/settings`, {
|
|
61
|
+
searchableAttributes: index.fields,
|
|
62
|
+
filterableAttributes: ['tenantId', ...(index.filterable ?? [])],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async index(indexName, document) {
|
|
66
|
+
await this.bulk(indexName, [document]);
|
|
67
|
+
}
|
|
68
|
+
async bulk(indexName, documents) {
|
|
69
|
+
assertValidIndexName(indexName);
|
|
70
|
+
const withPk = documents.map((d) => ({ ...d, _pk: primaryKey(d.tenantId, d.id) }));
|
|
71
|
+
await this.request('PUT', `/indexes/${indexName}/documents`, withPk);
|
|
72
|
+
}
|
|
73
|
+
async remove(indexName, tenantId, id) {
|
|
74
|
+
assertValidIndexName(indexName);
|
|
75
|
+
await this.request('DELETE', `/indexes/${indexName}/documents/${primaryKey(tenantId, id)}`);
|
|
76
|
+
}
|
|
77
|
+
async clear(indexName) {
|
|
78
|
+
assertValidIndexName(indexName);
|
|
79
|
+
await this.request('DELETE', `/indexes/${indexName}/documents`);
|
|
80
|
+
}
|
|
81
|
+
async search(indexName, query) {
|
|
82
|
+
assertValidIndexName(indexName);
|
|
83
|
+
const result = (await this.request('POST', `/indexes/${indexName}/search`, {
|
|
84
|
+
q: query.q,
|
|
85
|
+
filter: this.buildFilter(query.tenantId, query.filters),
|
|
86
|
+
limit: query.limit ?? 20,
|
|
87
|
+
offset: query.offset ?? 0,
|
|
88
|
+
showRankingScore: true,
|
|
89
|
+
}));
|
|
90
|
+
const hits = (result.hits ?? []).map((hit) => {
|
|
91
|
+
const { _pk, _rankingScore, ...document } = hit;
|
|
92
|
+
void _pk;
|
|
93
|
+
return {
|
|
94
|
+
id: String(document['id']),
|
|
95
|
+
score: typeof _rankingScore === 'number' ? _rankingScore : 0,
|
|
96
|
+
document: document,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
return { hits, total: result.estimatedTotalHits ?? hits.length };
|
|
100
|
+
}
|
|
101
|
+
buildFilter(tenantId, filters) {
|
|
102
|
+
const parts = [`tenantId = ${quote(tenantId)}`];
|
|
103
|
+
for (const [field, value] of Object.entries(filters ?? {})) {
|
|
104
|
+
if (!SAFE_FILTER_FIELD.test(field))
|
|
105
|
+
throw new SearchFilterFieldError(field);
|
|
106
|
+
parts.push(Array.isArray(value) ? `${field} IN [${value.map(quote).join(', ')}]` : `${field} = ${quote(value)}`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join(' AND ');
|
|
109
|
+
}
|
|
110
|
+
async request(method, path, body) {
|
|
111
|
+
const response = await this.fetch(`${this.options.host}${path}`, {
|
|
112
|
+
method,
|
|
113
|
+
headers: {
|
|
114
|
+
'content-type': 'application/json',
|
|
115
|
+
...(this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}),
|
|
116
|
+
},
|
|
117
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
118
|
+
});
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
const json = text ? JSON.parse(text) : {};
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const message = json.message ?? text ?? 'unknown error';
|
|
123
|
+
throw new MeilisearchError(response.status, message);
|
|
124
|
+
}
|
|
125
|
+
return json;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,172 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
declare class SearchFilterFieldError extends BasaltError {
|
|
107
|
-
constructor(field: string);
|
|
108
|
-
}
|
|
109
|
-
interface MeilisearchDriverOptions {
|
|
110
|
-
host: string;
|
|
111
|
-
apiKey?: string;
|
|
112
|
-
/** Injected fetch (tests). Default: global fetch. */
|
|
113
|
-
fetch?: typeof fetch;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Meilisearch driver targeting the REST API directly (no SDK). Documents get a
|
|
117
|
-
* compound `_pk` so ids never collide across tenants, and every search is
|
|
118
|
-
* constrained with a `tenantId` filter so results never leak between tenants.
|
|
119
|
-
*/
|
|
120
|
-
declare class MeilisearchDriver implements SearchDriver {
|
|
121
|
-
private readonly options;
|
|
122
|
-
private readonly fetch;
|
|
123
|
-
constructor(options: MeilisearchDriverOptions);
|
|
124
|
-
register(index: IndexDefinition): Promise<void>;
|
|
125
|
-
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
126
|
-
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
127
|
-
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
128
|
-
clear(indexName: string): Promise<void>;
|
|
129
|
-
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
130
|
-
private buildFilter;
|
|
131
|
-
private request;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
declare const SEARCH: _basaltkit_core.Token<Search>;
|
|
135
|
-
/**
|
|
136
|
-
* Keeps an index in sync with domain events: on the given hook, either upsert a
|
|
137
|
-
* document or remove one. Wire it once and your search index maintains itself.
|
|
138
|
-
*
|
|
139
|
-
* syncRule({ hook: 'note:created', index: 'notes', document: (p) => ({
|
|
140
|
-
* id: p.note.id, tenantId: p.tenantId, title: p.note.title, body: p.note.body,
|
|
141
|
-
* })})
|
|
142
|
-
*/
|
|
143
|
-
interface SyncRule<K extends keyof BasaltHooks & string = keyof BasaltHooks & string> {
|
|
144
|
-
hook: K;
|
|
145
|
-
index: string;
|
|
146
|
-
/** Build the document to upsert. Return null to skip. */
|
|
147
|
-
document?: (payload: BasaltHooks[K]) => SearchDocument | null;
|
|
148
|
-
/** Or the identifiers to remove. Return null to skip. */
|
|
149
|
-
remove?: (payload: BasaltHooks[K]) => {
|
|
150
|
-
tenantId: string;
|
|
151
|
-
id: string;
|
|
152
|
-
} | null;
|
|
153
|
-
}
|
|
154
|
-
/** Type-checks a sync rule against its hook, then erases the generic. */
|
|
155
|
-
declare function syncRule<K extends keyof BasaltHooks & string>(rule: SyncRule<K>): SyncRule;
|
|
156
|
-
interface SearchPluginOptions {
|
|
157
|
-
driver?: SearchDriver;
|
|
158
|
-
/** Indexes to register with the driver at boot. */
|
|
159
|
-
indexes?: IndexDefinition[];
|
|
160
|
-
/** Rules keeping indexes in sync with domain hooks. */
|
|
161
|
-
sync?: SyncRule[];
|
|
162
|
-
/**
|
|
163
|
-
* Throw if an index fails to register at boot. Default `false`: a search
|
|
164
|
-
* backend that's down or misconfigured logs a warning and the app boots
|
|
165
|
-
* anyway (search stays degraded until the backend is reachable), so an outage
|
|
166
|
-
* never blocks unrelated work — including CLI commands that don't use search.
|
|
167
|
-
*/
|
|
168
|
-
failOnRegisterError?: boolean;
|
|
169
|
-
}
|
|
170
|
-
declare function searchPlugin(options?: SearchPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
|
|
171
|
-
|
|
172
|
-
export { type IndexDefinition, MeilisearchDriver, type MeilisearchDriverOptions, MeilisearchError, MemorySearchDriver, SEARCH, Search, type SearchDocument, type SearchDriver, SearchFilterFieldError, type SearchHit, type SearchOptions, type SearchPluginOptions, type SearchQuery, type SearchResult, type SyncRule, TenantRequiredError, defineIndex, searchPlugin, syncRule };
|
|
1
|
+
export { defineIndex, type SearchDocument, type IndexDefinition, type SearchQuery, type SearchHit, type SearchResult, type SearchDriver, } from './types.js';
|
|
2
|
+
export { MemorySearchDriver } from './memory.js';
|
|
3
|
+
export { Search, TenantRequiredError, type SearchOptions } from './search.js';
|
|
4
|
+
export { MeilisearchDriver, MeilisearchError, SearchFilterFieldError, SearchIndexNameError, type MeilisearchDriverOptions, } from './drivers/meilisearch.js';
|
|
5
|
+
export { searchPlugin, syncRule, SEARCH, type SearchPluginOptions, type SyncRule, } from './plugin.js';
|
package/dist/index.js
CHANGED
|
@@ -1,269 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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 SearchFilterFieldError = class extends BasaltError2 {
|
|
132
|
-
constructor(field) {
|
|
133
|
-
super("SEARCH_INVALID_FILTER_FIELD", `Invalid filter field name: ${JSON.stringify(field)}.`);
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
var SAFE_FILTER_FIELD = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
137
|
-
var primaryKey = (tenantId, id) => Buffer.from(`${tenantId}::${id}`).toString("base64url");
|
|
138
|
-
var quote = (value) => JSON.stringify(value);
|
|
139
|
-
var MeilisearchDriver = class {
|
|
140
|
-
constructor(options) {
|
|
141
|
-
this.options = options;
|
|
142
|
-
this.fetch = options.fetch ?? globalThis.fetch;
|
|
143
|
-
}
|
|
144
|
-
options;
|
|
145
|
-
fetch;
|
|
146
|
-
async register(index) {
|
|
147
|
-
try {
|
|
148
|
-
await this.request("POST", "/indexes", { uid: index.name, primaryKey: "_pk" });
|
|
149
|
-
} catch {
|
|
150
|
-
}
|
|
151
|
-
await this.request("PATCH", `/indexes/${index.name}/settings`, {
|
|
152
|
-
searchableAttributes: index.fields,
|
|
153
|
-
filterableAttributes: ["tenantId", ...index.filterable ?? []]
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
async index(indexName, document) {
|
|
157
|
-
await this.bulk(indexName, [document]);
|
|
158
|
-
}
|
|
159
|
-
async bulk(indexName, documents) {
|
|
160
|
-
const withPk = documents.map((d) => ({ ...d, _pk: primaryKey(d.tenantId, d.id) }));
|
|
161
|
-
await this.request("PUT", `/indexes/${indexName}/documents`, withPk);
|
|
162
|
-
}
|
|
163
|
-
async remove(indexName, tenantId, id) {
|
|
164
|
-
await this.request("DELETE", `/indexes/${indexName}/documents/${primaryKey(tenantId, id)}`);
|
|
165
|
-
}
|
|
166
|
-
async clear(indexName) {
|
|
167
|
-
await this.request("DELETE", `/indexes/${indexName}/documents`);
|
|
168
|
-
}
|
|
169
|
-
async search(indexName, query) {
|
|
170
|
-
const result = await this.request("POST", `/indexes/${indexName}/search`, {
|
|
171
|
-
q: query.q,
|
|
172
|
-
filter: this.buildFilter(query.tenantId, query.filters),
|
|
173
|
-
limit: query.limit ?? 20,
|
|
174
|
-
offset: query.offset ?? 0,
|
|
175
|
-
showRankingScore: true
|
|
176
|
-
});
|
|
177
|
-
const hits = (result.hits ?? []).map((hit) => {
|
|
178
|
-
const { _pk, _rankingScore, ...document } = hit;
|
|
179
|
-
void _pk;
|
|
180
|
-
return {
|
|
181
|
-
id: String(document["id"]),
|
|
182
|
-
score: typeof _rankingScore === "number" ? _rankingScore : 0,
|
|
183
|
-
document
|
|
184
|
-
};
|
|
185
|
-
});
|
|
186
|
-
return { hits, total: result.estimatedTotalHits ?? hits.length };
|
|
187
|
-
}
|
|
188
|
-
buildFilter(tenantId, filters) {
|
|
189
|
-
const parts = [`tenantId = ${quote(tenantId)}`];
|
|
190
|
-
for (const [field, value] of Object.entries(filters ?? {})) {
|
|
191
|
-
if (!SAFE_FILTER_FIELD.test(field)) throw new SearchFilterFieldError(field);
|
|
192
|
-
parts.push(Array.isArray(value) ? `${field} IN [${value.map(quote).join(", ")}]` : `${field} = ${quote(value)}`);
|
|
193
|
-
}
|
|
194
|
-
return parts.join(" AND ");
|
|
195
|
-
}
|
|
196
|
-
async request(method, path, body) {
|
|
197
|
-
const response = await this.fetch(`${this.options.host}${path}`, {
|
|
198
|
-
method,
|
|
199
|
-
headers: {
|
|
200
|
-
"content-type": "application/json",
|
|
201
|
-
...this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}
|
|
202
|
-
},
|
|
203
|
-
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
204
|
-
});
|
|
205
|
-
const text = await response.text();
|
|
206
|
-
const json = text ? JSON.parse(text) : {};
|
|
207
|
-
if (!response.ok) {
|
|
208
|
-
const message = json.message ?? text ?? "unknown error";
|
|
209
|
-
throw new MeilisearchError(response.status, message);
|
|
210
|
-
}
|
|
211
|
-
return json;
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// src/plugin.ts
|
|
216
|
-
import { createToken, definePlugin } from "@basaltkit/core";
|
|
217
|
-
var SEARCH = createToken("search");
|
|
218
|
-
function syncRule(rule) {
|
|
219
|
-
return rule;
|
|
220
|
-
}
|
|
221
|
-
function searchPlugin(options = {}) {
|
|
222
|
-
const driver = options.driver ?? new MemorySearchDriver();
|
|
223
|
-
return definePlugin({
|
|
224
|
-
name: "basalt:search",
|
|
225
|
-
register({ container }) {
|
|
226
|
-
container.singleton(SEARCH, () => new Search({ driver }));
|
|
227
|
-
},
|
|
228
|
-
async boot({ container, hooks }) {
|
|
229
|
-
const search = container.get(SEARCH);
|
|
230
|
-
if (driver.register) {
|
|
231
|
-
for (const index of options.indexes ?? []) {
|
|
232
|
-
try {
|
|
233
|
-
await driver.register(index);
|
|
234
|
-
} catch (error) {
|
|
235
|
-
if (options.failOnRegisterError) throw error;
|
|
236
|
-
console.warn(
|
|
237
|
-
`[basalt:search] could not register index "${index.name}": ${String(
|
|
238
|
-
error?.message ?? error
|
|
239
|
-
)} \u2014 search is degraded until the backend is reachable.`
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
for (const rule of options.sync ?? []) {
|
|
245
|
-
hooks.on(rule.hook, async (payload) => {
|
|
246
|
-
if (rule.document) {
|
|
247
|
-
const document = rule.document(payload);
|
|
248
|
-
if (document) await search.index(rule.index, document);
|
|
249
|
-
} else if (rule.remove) {
|
|
250
|
-
const target = rule.remove(payload);
|
|
251
|
-
if (target) await search.remove(rule.index, target.id, target.tenantId);
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
export {
|
|
259
|
-
MeilisearchDriver,
|
|
260
|
-
MeilisearchError,
|
|
261
|
-
MemorySearchDriver,
|
|
262
|
-
SEARCH,
|
|
263
|
-
Search,
|
|
264
|
-
SearchFilterFieldError,
|
|
265
|
-
TenantRequiredError,
|
|
266
|
-
defineIndex,
|
|
267
|
-
searchPlugin,
|
|
268
|
-
syncRule
|
|
269
|
-
};
|
|
1
|
+
export { defineIndex, } from './types.js';
|
|
2
|
+
export { MemorySearchDriver } from './memory.js';
|
|
3
|
+
export { Search, TenantRequiredError } from './search.js';
|
|
4
|
+
export { MeilisearchDriver, MeilisearchError, SearchFilterFieldError, SearchIndexNameError, } from './drivers/meilisearch.js';
|
|
5
|
+
export { searchPlugin, syncRule, SEARCH, } from './plugin.js';
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IndexDefinition, SearchDocument, SearchDriver, SearchQuery, SearchResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-process full-text driver for dev and tests — no external engine. Scores by
|
|
4
|
+
* term frequency with prefix matching, requires every query term to match (AND
|
|
5
|
+
* semantics), scopes results to the query's tenant, and supports exact-match
|
|
6
|
+
* filters. Good enough for development; swap in {@link MeilisearchDriver} for
|
|
7
|
+
* production-scale relevance.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MemorySearchDriver implements SearchDriver {
|
|
10
|
+
private readonly documents;
|
|
11
|
+
private readonly configs;
|
|
12
|
+
register(index: IndexDefinition): Promise<void>;
|
|
13
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
14
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
15
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
16
|
+
clear(indexName: string): Promise<void>;
|
|
17
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
18
|
+
private score;
|
|
19
|
+
private passesFilters;
|
|
20
|
+
private searchableFields;
|
|
21
|
+
private store;
|
|
22
|
+
}
|
package/dist/memory.js
ADDED
|
Binary file
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { BasaltHooks } from '@basaltkit/core';
|
|
2
|
+
import { Search } from './search.js';
|
|
3
|
+
import type { IndexDefinition, SearchDocument, SearchDriver } from './types.js';
|
|
4
|
+
export declare const SEARCH: import("@basaltkit/core").Token<Search>;
|
|
5
|
+
/**
|
|
6
|
+
* Keeps an index in sync with domain events: on the given hook, either upsert a
|
|
7
|
+
* document or remove one. Wire it once and your search index maintains itself.
|
|
8
|
+
*
|
|
9
|
+
* syncRule({ hook: 'note:created', index: 'notes', document: (p) => ({
|
|
10
|
+
* id: p.note.id, tenantId: p.tenantId, title: p.note.title, body: p.note.body,
|
|
11
|
+
* })})
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncRule<K extends keyof BasaltHooks & string = keyof BasaltHooks & string> {
|
|
14
|
+
hook: K;
|
|
15
|
+
index: string;
|
|
16
|
+
/** Build the document to upsert. Return null to skip. */
|
|
17
|
+
document?: (payload: BasaltHooks[K]) => SearchDocument | null;
|
|
18
|
+
/** Or the identifiers to remove. Return null to skip. */
|
|
19
|
+
remove?: (payload: BasaltHooks[K]) => {
|
|
20
|
+
tenantId: string;
|
|
21
|
+
id: string;
|
|
22
|
+
} | null;
|
|
23
|
+
}
|
|
24
|
+
/** Type-checks a sync rule against its hook, then erases the generic. */
|
|
25
|
+
export declare function syncRule<K extends keyof BasaltHooks & string>(rule: SyncRule<K>): SyncRule;
|
|
26
|
+
export interface SearchPluginOptions {
|
|
27
|
+
driver?: SearchDriver;
|
|
28
|
+
/** Indexes to register with the driver at boot. */
|
|
29
|
+
indexes?: IndexDefinition[];
|
|
30
|
+
/** Rules keeping indexes in sync with domain hooks. */
|
|
31
|
+
sync?: SyncRule[];
|
|
32
|
+
/**
|
|
33
|
+
* Throw if an index fails to register at boot. Default `false`: a search
|
|
34
|
+
* backend that's down or misconfigured logs a warning and the app boots
|
|
35
|
+
* anyway (search stays degraded until the backend is reachable), so an outage
|
|
36
|
+
* never blocks unrelated work — including CLI commands that don't use search.
|
|
37
|
+
*/
|
|
38
|
+
failOnRegisterError?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare function searchPlugin(options?: SearchPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createToken, definePlugin } from '@basaltkit/core';
|
|
2
|
+
import { MemorySearchDriver } from './memory.js';
|
|
3
|
+
import { Search } from './search.js';
|
|
4
|
+
export const SEARCH = createToken('search');
|
|
5
|
+
/** Type-checks a sync rule against its hook, then erases the generic. */
|
|
6
|
+
export function syncRule(rule) {
|
|
7
|
+
return rule;
|
|
8
|
+
}
|
|
9
|
+
export function searchPlugin(options = {}) {
|
|
10
|
+
const driver = options.driver ?? new MemorySearchDriver();
|
|
11
|
+
return definePlugin({
|
|
12
|
+
name: 'basalt:search',
|
|
13
|
+
register({ container }) {
|
|
14
|
+
container.singleton(SEARCH, () => new Search({ driver }));
|
|
15
|
+
},
|
|
16
|
+
async boot({ container, hooks }) {
|
|
17
|
+
const search = container.get(SEARCH);
|
|
18
|
+
if (driver.register) {
|
|
19
|
+
for (const index of options.indexes ?? []) {
|
|
20
|
+
try {
|
|
21
|
+
await driver.register(index);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (options.failOnRegisterError)
|
|
25
|
+
throw error;
|
|
26
|
+
// Non-fatal by default: a search backend that's down/misconfigured
|
|
27
|
+
// shouldn't stop the app booting or block unrelated CLI commands.
|
|
28
|
+
console.warn(`[basalt:search] could not register index "${index.name}": ${String(error?.message ?? error)} — search is degraded until the backend is reachable.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const rule of options.sync ?? []) {
|
|
33
|
+
hooks.on(rule.hook, async (payload) => {
|
|
34
|
+
if (rule.document) {
|
|
35
|
+
const document = rule.document(payload);
|
|
36
|
+
if (document)
|
|
37
|
+
await search.index(rule.index, document);
|
|
38
|
+
}
|
|
39
|
+
else if (rule.remove) {
|
|
40
|
+
const target = rule.remove(payload);
|
|
41
|
+
if (target)
|
|
42
|
+
await search.remove(rule.index, target.id, target.tenantId);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { BasaltError } from '@basaltkit/core';
|
|
2
|
+
import type { SearchDocument, SearchDriver, SearchResult } from './types.js';
|
|
3
|
+
/** Search was asked for a tenant it couldn't determine. */
|
|
4
|
+
export declare class TenantRequiredError extends BasaltError {
|
|
5
|
+
readonly status = 400;
|
|
6
|
+
constructor();
|
|
7
|
+
}
|
|
8
|
+
export interface SearchOptions {
|
|
9
|
+
/** Defaults to the current tenant (`ctx().tenant.id`). */
|
|
10
|
+
tenantId?: string;
|
|
11
|
+
filters?: Record<string, unknown>;
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Tenant-scoped full-text search. Indexing takes the tenant from the document;
|
|
17
|
+
* querying takes it from `options.tenantId` or the current request context.
|
|
18
|
+
*/
|
|
19
|
+
export declare class Search {
|
|
20
|
+
private readonly driver;
|
|
21
|
+
constructor(options?: {
|
|
22
|
+
driver?: SearchDriver;
|
|
23
|
+
});
|
|
24
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
25
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
26
|
+
remove(indexName: string, id: string, tenantId?: string): Promise<void>;
|
|
27
|
+
search(indexName: string, q: string, options?: SearchOptions): Promise<SearchResult>;
|
|
28
|
+
}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BasaltError, tryCtx } from '@basaltkit/core';
|
|
2
|
+
import { MemorySearchDriver } from './memory.js';
|
|
3
|
+
/** Search was asked for a tenant it couldn't determine. */
|
|
4
|
+
export class TenantRequiredError extends BasaltError {
|
|
5
|
+
status = 400;
|
|
6
|
+
constructor() {
|
|
7
|
+
super('SEARCH_TENANT_REQUIRED', 'A tenant is required — pass tenantId or run inside a tenant context.');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const currentTenant = (explicit) => {
|
|
11
|
+
const id = explicit ?? tryCtx()?.['tenant']?.id;
|
|
12
|
+
if (!id)
|
|
13
|
+
throw new TenantRequiredError();
|
|
14
|
+
return id;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Tenant-scoped full-text search. Indexing takes the tenant from the document;
|
|
18
|
+
* querying takes it from `options.tenantId` or the current request context.
|
|
19
|
+
*/
|
|
20
|
+
export class Search {
|
|
21
|
+
driver;
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.driver = options.driver ?? new MemorySearchDriver();
|
|
24
|
+
}
|
|
25
|
+
index(indexName, document) {
|
|
26
|
+
return this.driver.index(indexName, document);
|
|
27
|
+
}
|
|
28
|
+
bulk(indexName, documents) {
|
|
29
|
+
return this.driver.bulk(indexName, documents);
|
|
30
|
+
}
|
|
31
|
+
async remove(indexName, id, tenantId) {
|
|
32
|
+
return this.driver.remove(indexName, currentTenant(tenantId), id);
|
|
33
|
+
}
|
|
34
|
+
async search(indexName, q, options = {}) {
|
|
35
|
+
return this.driver.search(indexName, {
|
|
36
|
+
tenantId: currentTenant(options.tenantId),
|
|
37
|
+
q,
|
|
38
|
+
...(options.filters ? { filters: options.filters } : {}),
|
|
39
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
40
|
+
...(options.offset !== undefined ? { offset: options.offset } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A document to index. Always carries an `id` and the owning `tenantId`. */
|
|
2
|
+
export interface SearchDocument {
|
|
3
|
+
id: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
[field: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
/** Declares an index: which fields are searchable (full-text) and filterable. */
|
|
8
|
+
export interface IndexDefinition {
|
|
9
|
+
name: string;
|
|
10
|
+
/** Text fields matched by the query. */
|
|
11
|
+
fields: string[];
|
|
12
|
+
/** Fields usable in `filters` (exact match). `tenantId` is always filterable. */
|
|
13
|
+
filterable?: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function defineIndex(definition: IndexDefinition): IndexDefinition;
|
|
16
|
+
export interface SearchQuery {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
q: string;
|
|
19
|
+
/** Exact-match filters. A value array means "any of". */
|
|
20
|
+
filters?: Record<string, unknown>;
|
|
21
|
+
limit?: number;
|
|
22
|
+
offset?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface SearchHit {
|
|
25
|
+
id: string;
|
|
26
|
+
score: number;
|
|
27
|
+
document: SearchDocument;
|
|
28
|
+
}
|
|
29
|
+
export interface SearchResult {
|
|
30
|
+
hits: SearchHit[];
|
|
31
|
+
/** Total matches (before limit/offset). */
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Search backend contract. The core talks to this; drivers talk to the engine.
|
|
36
|
+
* Every operation is tenant-scoped: `search` must only ever return documents
|
|
37
|
+
* whose `tenantId` matches the query.
|
|
38
|
+
*/
|
|
39
|
+
export interface SearchDriver {
|
|
40
|
+
/** Optional: called once per index at boot with its config. */
|
|
41
|
+
register?(index: IndexDefinition): Promise<void>;
|
|
42
|
+
index(indexName: string, document: SearchDocument): Promise<void>;
|
|
43
|
+
bulk(indexName: string, documents: SearchDocument[]): Promise<void>;
|
|
44
|
+
remove(indexName: string, tenantId: string, id: string): Promise<void>;
|
|
45
|
+
search(indexName: string, query: SearchQuery): Promise<SearchResult>;
|
|
46
|
+
/** Drops every document in the index (used by tests). */
|
|
47
|
+
clear(indexName: string): Promise<void>;
|
|
48
|
+
}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@basaltkit/core": "^1.
|
|
17
|
+
"@basaltkit/core": "^1.1.2"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/node": "^
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"vitest": "^3.1.0",
|
|
20
|
+
"@types/node": "^26.3.0",
|
|
21
|
+
"typescript": "^7.0.2",
|
|
22
|
+
"vitest": "^4.1.11",
|
|
24
23
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
25
24
|
},
|
|
26
25
|
"publishConfig": {
|
|
@@ -28,11 +27,11 @@
|
|
|
28
27
|
},
|
|
29
28
|
"repository": {
|
|
30
29
|
"type": "git",
|
|
31
|
-
"url": "git+https://github.com/
|
|
30
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
32
31
|
"directory": "packages/search"
|
|
33
32
|
},
|
|
34
|
-
"homepage": "https://github.com/
|
|
35
|
-
"bugs": "https://github.com/
|
|
33
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/search#readme",
|
|
34
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
36
35
|
"keywords": [
|
|
37
36
|
"basalt",
|
|
38
37
|
"typescript",
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
"meilisearch"
|
|
43
42
|
],
|
|
44
43
|
"scripts": {
|
|
45
|
-
"build": "
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
45
|
"test": "vitest run",
|
|
47
46
|
"typecheck": "tsc --noEmit"
|
|
48
47
|
}
|