@zero-server/sdk 0.9.0 → 0.9.2
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 -21
- package/README.md +460 -437
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +460 -460
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +136 -136
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +254 -254
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
package/lib/orm/search.js
CHANGED
|
@@ -1,380 +1,380 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module orm/search
|
|
3
|
-
* @description Full-text search integration for the ORM.
|
|
4
|
-
* Provides a unified API across PostgreSQL (tsvector/tsquery),
|
|
5
|
-
* MySQL (FULLTEXT), SQLite (FTS5), and in-memory (regex-based).
|
|
6
|
-
*
|
|
7
|
-
* @section Full-Text Search
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* const { FullTextSearch } = require('@zero-server/sdk');
|
|
11
|
-
*
|
|
12
|
-
* // Create a search index
|
|
13
|
-
* const search = new FullTextSearch(Article, {
|
|
14
|
-
* fields: ['title', 'body'],
|
|
15
|
-
* weights: { title: 'A', body: 'B' },
|
|
16
|
-
* });
|
|
17
|
-
*
|
|
18
|
-
* // Create the index in the database
|
|
19
|
-
* await search.createIndex(db);
|
|
20
|
-
*
|
|
21
|
-
* // Search
|
|
22
|
-
* const results = await search.search('javascript framework');
|
|
23
|
-
* const ranked = await search.search('node.js', { rank: true, limit: 10 });
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
const log = require('../debug')('zero:orm:search');
|
|
27
|
-
|
|
28
|
-
// -- FullTextSearch class ---------------------------------
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Full-text search engine for ORM models.
|
|
32
|
-
* Provides a unified search API that adapts to the underlying database engine.
|
|
33
|
-
*
|
|
34
|
-
* @param {typeof Model} ModelClass - Model class to search.
|
|
35
|
-
* @param {object} options - Search configuration.
|
|
36
|
-
* @param {string[]} options.fields - Column names to include in the search index.
|
|
37
|
-
* @param {Object<string, string>} [options.weights] - Weight map for fields. PostgreSQL: 'A'–'D'. Others: numeric multiplier.
|
|
38
|
-
* @param {string} [options.language='english'] - Language for stemming/tokenisation.
|
|
39
|
-
* @param {string} [options.indexName] - Custom index name.
|
|
40
|
-
*/
|
|
41
|
-
class FullTextSearch
|
|
42
|
-
{
|
|
43
|
-
/**
|
|
44
|
-
* @constructor
|
|
45
|
-
* @param {typeof Model} ModelClass - Model class to search.
|
|
46
|
-
* @param {object} options - Configuration options.
|
|
47
|
-
* @param {string[]} options.fields - Column names to include in the search index.
|
|
48
|
-
* @param {Object<string, string>} [options.weights] - Weight map for fields (e.g. `{ title: 'A', body: 'B' }`).
|
|
49
|
-
* @param {string} [options.language='english'] - Language for stemming.
|
|
50
|
-
* @param {string} [options.indexName] - Custom index name.
|
|
51
|
-
*/
|
|
52
|
-
constructor(ModelClass, options = {})
|
|
53
|
-
{
|
|
54
|
-
if (!ModelClass) throw new Error('FullTextSearch requires a Model class');
|
|
55
|
-
if (!options.fields || !options.fields.length) throw new Error('FullTextSearch requires at least one field');
|
|
56
|
-
|
|
57
|
-
/** @type {typeof Model} */
|
|
58
|
-
this._model = ModelClass;
|
|
59
|
-
|
|
60
|
-
/** @type {string[]} Fields to index. */
|
|
61
|
-
this._fields = options.fields;
|
|
62
|
-
|
|
63
|
-
/** @type {Object<string, string>} Field weight configuration. */
|
|
64
|
-
this._weights = options.weights || {};
|
|
65
|
-
|
|
66
|
-
/** @type {string} Language for stemming. */
|
|
67
|
-
this._language = options.language || 'english';
|
|
68
|
-
|
|
69
|
-
/** @type {string} Index name. */
|
|
70
|
-
this._indexName = options.indexName || `fts_${ModelClass.table}_${this._fields.join('_')}`;
|
|
71
|
-
|
|
72
|
-
/** @type {object|null} Database adapter. */
|
|
73
|
-
this._adapter = null;
|
|
74
|
-
|
|
75
|
-
/** @type {string|null} Detected adapter type. */
|
|
76
|
-
this._adapterType = null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Create the full-text search index.
|
|
81
|
-
* Adapts to the underlying database:
|
|
82
|
-
* - PostgreSQL: creates a GIN index on tsvector columns
|
|
83
|
-
* - MySQL: creates a FULLTEXT index
|
|
84
|
-
* - SQLite: creates an FTS5 virtual table
|
|
85
|
-
* - Memory/JSON: no-op (search operates with in-memory regex)
|
|
86
|
-
*
|
|
87
|
-
* @param {object} db - Database instance.
|
|
88
|
-
* @returns {Promise<FullTextSearch>} `this` for chaining.
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* await search.createIndex(db);
|
|
92
|
-
*/
|
|
93
|
-
async createIndex(db)
|
|
94
|
-
{
|
|
95
|
-
this._adapter = db.adapter;
|
|
96
|
-
this._adapterType = this._detectAdapterType();
|
|
97
|
-
|
|
98
|
-
if (typeof this._adapter.createFullTextIndex === 'function')
|
|
99
|
-
{
|
|
100
|
-
await this._adapter.createFullTextIndex(this._model.table, this._fields, {
|
|
101
|
-
name: this._indexName,
|
|
102
|
-
weights: this._weights,
|
|
103
|
-
language: this._language,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
log.debug('fts index %s created on %s', this._indexName, this._model.table);
|
|
108
|
-
return this;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Drop the full-text search index.
|
|
113
|
-
*
|
|
114
|
-
* @param {object} db - Database instance.
|
|
115
|
-
* @returns {Promise<void>}
|
|
116
|
-
*/
|
|
117
|
-
async dropIndex(db)
|
|
118
|
-
{
|
|
119
|
-
const adapter = db ? db.adapter : this._adapter;
|
|
120
|
-
if (!adapter) throw new Error('No database adapter available');
|
|
121
|
-
|
|
122
|
-
if (typeof adapter.dropFullTextIndex === 'function')
|
|
123
|
-
{
|
|
124
|
-
await adapter.dropFullTextIndex(this._model.table, this._indexName);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
log.debug('fts index %s dropped', this._indexName);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Perform a full-text search.
|
|
132
|
-
*
|
|
133
|
-
* @param {string} query - Search query string.
|
|
134
|
-
* @param {object} [options] - Search options.
|
|
135
|
-
* @param {boolean} [options.rank=false] - Include relevance ranking in results.
|
|
136
|
-
* @param {number} [options.limit] - Maximum number of results.
|
|
137
|
-
* @param {number} [options.offset] - Offset for pagination.
|
|
138
|
-
* @param {object} [options.where] - Additional WHERE conditions.
|
|
139
|
-
* @param {string} [options.orderBy] - Custom order ('rank' for relevance, or a column name).
|
|
140
|
-
* @returns {Promise<Array<object>>} Search results, optionally with `_rank` scores.
|
|
141
|
-
*
|
|
142
|
-
* @example
|
|
143
|
-
* // Simple search
|
|
144
|
-
* const results = await search.search('javascript');
|
|
145
|
-
*
|
|
146
|
-
* // Ranked search with filters
|
|
147
|
-
* const results = await search.search('node.js framework', {
|
|
148
|
-
* rank: true,
|
|
149
|
-
* limit: 10,
|
|
150
|
-
* where: { published: true },
|
|
151
|
-
* });
|
|
152
|
-
* // results[0]._rank => 0.95 (relevance score)
|
|
153
|
-
*/
|
|
154
|
-
async search(query, options = {})
|
|
155
|
-
{
|
|
156
|
-
if (!query || typeof query !== 'string') return [];
|
|
157
|
-
|
|
158
|
-
const adapter = this._adapter || this._model._adapter;
|
|
159
|
-
if (!adapter) throw new Error('Model is not registered with a database');
|
|
160
|
-
|
|
161
|
-
// Use adapter native FTS if available
|
|
162
|
-
if (typeof adapter.fullTextSearch === 'function')
|
|
163
|
-
{
|
|
164
|
-
return adapter.fullTextSearch(this._model.table, this._fields, query, {
|
|
165
|
-
...options,
|
|
166
|
-
language: this._language,
|
|
167
|
-
weights: this._weights,
|
|
168
|
-
model: this._model,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Fallback: in-memory search
|
|
173
|
-
return this._memorySearch(query, options);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Search and return model instances instead of plain objects.
|
|
178
|
-
*
|
|
179
|
-
* @param {string} query - Search query string.
|
|
180
|
-
* @param {object} [options] - Search options (same as search()).
|
|
181
|
-
* @returns {Promise<Array<Model>>} Model instances matching the search query.
|
|
182
|
-
*
|
|
183
|
-
* @example
|
|
184
|
-
* const articles = await search.searchModels('javascript');
|
|
185
|
-
* articles[0].title // => 'Learning JavaScript'
|
|
186
|
-
*/
|
|
187
|
-
async searchModels(query, options = {})
|
|
188
|
-
{
|
|
189
|
-
const rows = await this.search(query, options);
|
|
190
|
-
return rows.map(row =>
|
|
191
|
-
{
|
|
192
|
-
const inst = this._model._fromRow(row);
|
|
193
|
-
if (row._rank !== undefined) inst._rank = row._rank;
|
|
194
|
-
return inst;
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Count matching search results.
|
|
200
|
-
*
|
|
201
|
-
* @param {string} query - Search query string.
|
|
202
|
-
* @param {object} [options] - Additional WHERE conditions in `options.where`.
|
|
203
|
-
* @returns {Promise<number>} Number of matching records.
|
|
204
|
-
*/
|
|
205
|
-
async count(query, options = {})
|
|
206
|
-
{
|
|
207
|
-
const results = await this.search(query, { ...options, rank: false });
|
|
208
|
-
return results.length;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Build search suggestions (autocomplete) from indexed fields.
|
|
213
|
-
*
|
|
214
|
-
* @param {string} prefix - Partial search term.
|
|
215
|
-
* @param {object} [options] - Configuration options.
|
|
216
|
-
* @param {number} [options.limit=10] - Max suggestions.
|
|
217
|
-
* @param {string} [options.field] - Specific field to suggest from.
|
|
218
|
-
* @returns {Promise<string[]>} Matching suggestions.
|
|
219
|
-
*
|
|
220
|
-
* @example
|
|
221
|
-
* const suggestions = await search.suggest('jav', { limit: 5 });
|
|
222
|
-
* // => ['JavaScript', 'Java', 'Javelin']
|
|
223
|
-
*/
|
|
224
|
-
async suggest(prefix, options = {})
|
|
225
|
-
{
|
|
226
|
-
const { limit = 10, field } = options;
|
|
227
|
-
if (!prefix || typeof prefix !== 'string') return [];
|
|
228
|
-
|
|
229
|
-
const searchFields = field ? [field] : this._fields;
|
|
230
|
-
const adapter = this._adapter || this._model._adapter;
|
|
231
|
-
if (!adapter) throw new Error('Model is not registered with a database');
|
|
232
|
-
|
|
233
|
-
// Use adapter-native suggest if available
|
|
234
|
-
if (typeof adapter.fullTextSuggest === 'function')
|
|
235
|
-
{
|
|
236
|
-
return adapter.fullTextSuggest(this._model.table, searchFields, prefix, { limit });
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Fallback: in-memory suggestion
|
|
240
|
-
const q = this._model.query();
|
|
241
|
-
const results = await q.exec();
|
|
242
|
-
const seen = new Set();
|
|
243
|
-
const suggestions = [];
|
|
244
|
-
const lowerPrefix = prefix.toLowerCase();
|
|
245
|
-
|
|
246
|
-
for (const row of results)
|
|
247
|
-
{
|
|
248
|
-
for (const f of searchFields)
|
|
249
|
-
{
|
|
250
|
-
const val = row[f];
|
|
251
|
-
if (!val) continue;
|
|
252
|
-
const words = String(val).split(/\s+/);
|
|
253
|
-
for (const word of words)
|
|
254
|
-
{
|
|
255
|
-
const lower = word.toLowerCase();
|
|
256
|
-
if (lower.startsWith(lowerPrefix) && !seen.has(lower))
|
|
257
|
-
{
|
|
258
|
-
seen.add(lower);
|
|
259
|
-
suggestions.push(word);
|
|
260
|
-
if (suggestions.length >= limit) return suggestions;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return suggestions;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* In-memory full-text search using regex matching and scoring.
|
|
271
|
-
* Used as fallback for memory/json adapters.
|
|
272
|
-
* @param {string} query - Search query.
|
|
273
|
-
* @param {object} options - Search options.
|
|
274
|
-
* @returns {Promise<Array>} Matching rows with optional _rank.
|
|
275
|
-
* @private
|
|
276
|
-
*/
|
|
277
|
-
async _memorySearch(query, options = {})
|
|
278
|
-
{
|
|
279
|
-
const { rank = false, limit, offset = 0, where = {} } = options;
|
|
280
|
-
|
|
281
|
-
// Tokenise query into words
|
|
282
|
-
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
283
|
-
if (!tokens.length) return [];
|
|
284
|
-
|
|
285
|
-
// Build regex for each token (escape special chars)
|
|
286
|
-
const patterns = tokens.map(t =>
|
|
287
|
-
{
|
|
288
|
-
const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
289
|
-
return new RegExp(escaped, 'i');
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// Get all records, with optional WHERE conditions
|
|
293
|
-
let q = this._model.query();
|
|
294
|
-
if (Object.keys(where).length) q = q.where(where);
|
|
295
|
-
const allRows = await q.exec();
|
|
296
|
-
|
|
297
|
-
// Score each row
|
|
298
|
-
const scored = [];
|
|
299
|
-
for (const row of allRows)
|
|
300
|
-
{
|
|
301
|
-
let score = 0;
|
|
302
|
-
let matched = false;
|
|
303
|
-
|
|
304
|
-
for (const field of this._fields)
|
|
305
|
-
{
|
|
306
|
-
const val = row[field];
|
|
307
|
-
if (!val) continue;
|
|
308
|
-
const text = String(val).toLowerCase();
|
|
309
|
-
const weight = this._getWeight(field);
|
|
310
|
-
|
|
311
|
-
for (const pattern of patterns)
|
|
312
|
-
{
|
|
313
|
-
const matches = text.match(new RegExp(pattern.source, 'gi'));
|
|
314
|
-
if (matches)
|
|
315
|
-
{
|
|
316
|
-
matched = true;
|
|
317
|
-
score += matches.length * weight;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (matched)
|
|
323
|
-
{
|
|
324
|
-
const data = row.toJSON ? row.toJSON() : { ...row };
|
|
325
|
-
if (rank) data._rank = score;
|
|
326
|
-
scored.push({ data, score });
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Sort by score (descending)
|
|
331
|
-
scored.sort((a, b) => b.score - a.score);
|
|
332
|
-
|
|
333
|
-
// Apply pagination
|
|
334
|
-
let results = scored.map(s => s.data);
|
|
335
|
-
if (offset) results = results.slice(offset);
|
|
336
|
-
if (limit) results = results.slice(0, limit);
|
|
337
|
-
|
|
338
|
-
return results;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Get numeric weight for a field.
|
|
343
|
-
* @param {string} field - Field name.
|
|
344
|
-
* @returns {number} Numeric weight multiplier.
|
|
345
|
-
* @private
|
|
346
|
-
*/
|
|
347
|
-
_getWeight(field)
|
|
348
|
-
{
|
|
349
|
-
const w = this._weights[field];
|
|
350
|
-
if (!w) return 1;
|
|
351
|
-
// PostgreSQL-style weights: A=4, B=3, C=2, D=1
|
|
352
|
-
if (typeof w === 'string')
|
|
353
|
-
{
|
|
354
|
-
const map = { A: 4, B: 3, C: 2, D: 1 };
|
|
355
|
-
return map[w.toUpperCase()] || 1;
|
|
356
|
-
}
|
|
357
|
-
return Number(w) || 1;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Detect the adapter type from its constructor or methods.
|
|
362
|
-
* @returns {string} Adapter type identifier.
|
|
363
|
-
* @private
|
|
364
|
-
*/
|
|
365
|
-
_detectAdapterType()
|
|
366
|
-
{
|
|
367
|
-
const adapter = this._adapter;
|
|
368
|
-
if (!adapter) return 'memory';
|
|
369
|
-
const name = adapter.constructor.name.toLowerCase();
|
|
370
|
-
if (name.includes('postgres')) return 'postgres';
|
|
371
|
-
if (name.includes('mysql')) return 'mysql';
|
|
372
|
-
if (name.includes('sqlite')) return 'sqlite';
|
|
373
|
-
if (name.includes('mongo')) return 'mongo';
|
|
374
|
-
if (name.includes('redis')) return 'redis';
|
|
375
|
-
if (name.includes('json')) return 'json';
|
|
376
|
-
return 'memory';
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
module.exports = { FullTextSearch };
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/search
|
|
3
|
+
* @description Full-text search integration for the ORM.
|
|
4
|
+
* Provides a unified API across PostgreSQL (tsvector/tsquery),
|
|
5
|
+
* MySQL (FULLTEXT), SQLite (FTS5), and in-memory (regex-based).
|
|
6
|
+
*
|
|
7
|
+
* @section Full-Text Search
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const { FullTextSearch } = require('@zero-server/sdk');
|
|
11
|
+
*
|
|
12
|
+
* // Create a search index
|
|
13
|
+
* const search = new FullTextSearch(Article, {
|
|
14
|
+
* fields: ['title', 'body'],
|
|
15
|
+
* weights: { title: 'A', body: 'B' },
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Create the index in the database
|
|
19
|
+
* await search.createIndex(db);
|
|
20
|
+
*
|
|
21
|
+
* // Search
|
|
22
|
+
* const results = await search.search('javascript framework');
|
|
23
|
+
* const ranked = await search.search('node.js', { rank: true, limit: 10 });
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const log = require('../debug')('zero:orm:search');
|
|
27
|
+
|
|
28
|
+
// -- FullTextSearch class ---------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Full-text search engine for ORM models.
|
|
32
|
+
* Provides a unified search API that adapts to the underlying database engine.
|
|
33
|
+
*
|
|
34
|
+
* @param {typeof Model} ModelClass - Model class to search.
|
|
35
|
+
* @param {object} options - Search configuration.
|
|
36
|
+
* @param {string[]} options.fields - Column names to include in the search index.
|
|
37
|
+
* @param {Object<string, string>} [options.weights] - Weight map for fields. PostgreSQL: 'A'–'D'. Others: numeric multiplier.
|
|
38
|
+
* @param {string} [options.language='english'] - Language for stemming/tokenisation.
|
|
39
|
+
* @param {string} [options.indexName] - Custom index name.
|
|
40
|
+
*/
|
|
41
|
+
class FullTextSearch
|
|
42
|
+
{
|
|
43
|
+
/**
|
|
44
|
+
* @constructor
|
|
45
|
+
* @param {typeof Model} ModelClass - Model class to search.
|
|
46
|
+
* @param {object} options - Configuration options.
|
|
47
|
+
* @param {string[]} options.fields - Column names to include in the search index.
|
|
48
|
+
* @param {Object<string, string>} [options.weights] - Weight map for fields (e.g. `{ title: 'A', body: 'B' }`).
|
|
49
|
+
* @param {string} [options.language='english'] - Language for stemming.
|
|
50
|
+
* @param {string} [options.indexName] - Custom index name.
|
|
51
|
+
*/
|
|
52
|
+
constructor(ModelClass, options = {})
|
|
53
|
+
{
|
|
54
|
+
if (!ModelClass) throw new Error('FullTextSearch requires a Model class');
|
|
55
|
+
if (!options.fields || !options.fields.length) throw new Error('FullTextSearch requires at least one field');
|
|
56
|
+
|
|
57
|
+
/** @type {typeof Model} */
|
|
58
|
+
this._model = ModelClass;
|
|
59
|
+
|
|
60
|
+
/** @type {string[]} Fields to index. */
|
|
61
|
+
this._fields = options.fields;
|
|
62
|
+
|
|
63
|
+
/** @type {Object<string, string>} Field weight configuration. */
|
|
64
|
+
this._weights = options.weights || {};
|
|
65
|
+
|
|
66
|
+
/** @type {string} Language for stemming. */
|
|
67
|
+
this._language = options.language || 'english';
|
|
68
|
+
|
|
69
|
+
/** @type {string} Index name. */
|
|
70
|
+
this._indexName = options.indexName || `fts_${ModelClass.table}_${this._fields.join('_')}`;
|
|
71
|
+
|
|
72
|
+
/** @type {object|null} Database adapter. */
|
|
73
|
+
this._adapter = null;
|
|
74
|
+
|
|
75
|
+
/** @type {string|null} Detected adapter type. */
|
|
76
|
+
this._adapterType = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create the full-text search index.
|
|
81
|
+
* Adapts to the underlying database:
|
|
82
|
+
* - PostgreSQL: creates a GIN index on tsvector columns
|
|
83
|
+
* - MySQL: creates a FULLTEXT index
|
|
84
|
+
* - SQLite: creates an FTS5 virtual table
|
|
85
|
+
* - Memory/JSON: no-op (search operates with in-memory regex)
|
|
86
|
+
*
|
|
87
|
+
* @param {object} db - Database instance.
|
|
88
|
+
* @returns {Promise<FullTextSearch>} `this` for chaining.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* await search.createIndex(db);
|
|
92
|
+
*/
|
|
93
|
+
async createIndex(db)
|
|
94
|
+
{
|
|
95
|
+
this._adapter = db.adapter;
|
|
96
|
+
this._adapterType = this._detectAdapterType();
|
|
97
|
+
|
|
98
|
+
if (typeof this._adapter.createFullTextIndex === 'function')
|
|
99
|
+
{
|
|
100
|
+
await this._adapter.createFullTextIndex(this._model.table, this._fields, {
|
|
101
|
+
name: this._indexName,
|
|
102
|
+
weights: this._weights,
|
|
103
|
+
language: this._language,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log.debug('fts index %s created on %s', this._indexName, this._model.table);
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Drop the full-text search index.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} db - Database instance.
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
async dropIndex(db)
|
|
118
|
+
{
|
|
119
|
+
const adapter = db ? db.adapter : this._adapter;
|
|
120
|
+
if (!adapter) throw new Error('No database adapter available');
|
|
121
|
+
|
|
122
|
+
if (typeof adapter.dropFullTextIndex === 'function')
|
|
123
|
+
{
|
|
124
|
+
await adapter.dropFullTextIndex(this._model.table, this._indexName);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log.debug('fts index %s dropped', this._indexName);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Perform a full-text search.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} query - Search query string.
|
|
134
|
+
* @param {object} [options] - Search options.
|
|
135
|
+
* @param {boolean} [options.rank=false] - Include relevance ranking in results.
|
|
136
|
+
* @param {number} [options.limit] - Maximum number of results.
|
|
137
|
+
* @param {number} [options.offset] - Offset for pagination.
|
|
138
|
+
* @param {object} [options.where] - Additional WHERE conditions.
|
|
139
|
+
* @param {string} [options.orderBy] - Custom order ('rank' for relevance, or a column name).
|
|
140
|
+
* @returns {Promise<Array<object>>} Search results, optionally with `_rank` scores.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* // Simple search
|
|
144
|
+
* const results = await search.search('javascript');
|
|
145
|
+
*
|
|
146
|
+
* // Ranked search with filters
|
|
147
|
+
* const results = await search.search('node.js framework', {
|
|
148
|
+
* rank: true,
|
|
149
|
+
* limit: 10,
|
|
150
|
+
* where: { published: true },
|
|
151
|
+
* });
|
|
152
|
+
* // results[0]._rank => 0.95 (relevance score)
|
|
153
|
+
*/
|
|
154
|
+
async search(query, options = {})
|
|
155
|
+
{
|
|
156
|
+
if (!query || typeof query !== 'string') return [];
|
|
157
|
+
|
|
158
|
+
const adapter = this._adapter || this._model._adapter;
|
|
159
|
+
if (!adapter) throw new Error('Model is not registered with a database');
|
|
160
|
+
|
|
161
|
+
// Use adapter native FTS if available
|
|
162
|
+
if (typeof adapter.fullTextSearch === 'function')
|
|
163
|
+
{
|
|
164
|
+
return adapter.fullTextSearch(this._model.table, this._fields, query, {
|
|
165
|
+
...options,
|
|
166
|
+
language: this._language,
|
|
167
|
+
weights: this._weights,
|
|
168
|
+
model: this._model,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fallback: in-memory search
|
|
173
|
+
return this._memorySearch(query, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Search and return model instances instead of plain objects.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} query - Search query string.
|
|
180
|
+
* @param {object} [options] - Search options (same as search()).
|
|
181
|
+
* @returns {Promise<Array<Model>>} Model instances matching the search query.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* const articles = await search.searchModels('javascript');
|
|
185
|
+
* articles[0].title // => 'Learning JavaScript'
|
|
186
|
+
*/
|
|
187
|
+
async searchModels(query, options = {})
|
|
188
|
+
{
|
|
189
|
+
const rows = await this.search(query, options);
|
|
190
|
+
return rows.map(row =>
|
|
191
|
+
{
|
|
192
|
+
const inst = this._model._fromRow(row);
|
|
193
|
+
if (row._rank !== undefined) inst._rank = row._rank;
|
|
194
|
+
return inst;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Count matching search results.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} query - Search query string.
|
|
202
|
+
* @param {object} [options] - Additional WHERE conditions in `options.where`.
|
|
203
|
+
* @returns {Promise<number>} Number of matching records.
|
|
204
|
+
*/
|
|
205
|
+
async count(query, options = {})
|
|
206
|
+
{
|
|
207
|
+
const results = await this.search(query, { ...options, rank: false });
|
|
208
|
+
return results.length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build search suggestions (autocomplete) from indexed fields.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} prefix - Partial search term.
|
|
215
|
+
* @param {object} [options] - Configuration options.
|
|
216
|
+
* @param {number} [options.limit=10] - Max suggestions.
|
|
217
|
+
* @param {string} [options.field] - Specific field to suggest from.
|
|
218
|
+
* @returns {Promise<string[]>} Matching suggestions.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* const suggestions = await search.suggest('jav', { limit: 5 });
|
|
222
|
+
* // => ['JavaScript', 'Java', 'Javelin']
|
|
223
|
+
*/
|
|
224
|
+
async suggest(prefix, options = {})
|
|
225
|
+
{
|
|
226
|
+
const { limit = 10, field } = options;
|
|
227
|
+
if (!prefix || typeof prefix !== 'string') return [];
|
|
228
|
+
|
|
229
|
+
const searchFields = field ? [field] : this._fields;
|
|
230
|
+
const adapter = this._adapter || this._model._adapter;
|
|
231
|
+
if (!adapter) throw new Error('Model is not registered with a database');
|
|
232
|
+
|
|
233
|
+
// Use adapter-native suggest if available
|
|
234
|
+
if (typeof adapter.fullTextSuggest === 'function')
|
|
235
|
+
{
|
|
236
|
+
return adapter.fullTextSuggest(this._model.table, searchFields, prefix, { limit });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Fallback: in-memory suggestion
|
|
240
|
+
const q = this._model.query();
|
|
241
|
+
const results = await q.exec();
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
const suggestions = [];
|
|
244
|
+
const lowerPrefix = prefix.toLowerCase();
|
|
245
|
+
|
|
246
|
+
for (const row of results)
|
|
247
|
+
{
|
|
248
|
+
for (const f of searchFields)
|
|
249
|
+
{
|
|
250
|
+
const val = row[f];
|
|
251
|
+
if (!val) continue;
|
|
252
|
+
const words = String(val).split(/\s+/);
|
|
253
|
+
for (const word of words)
|
|
254
|
+
{
|
|
255
|
+
const lower = word.toLowerCase();
|
|
256
|
+
if (lower.startsWith(lowerPrefix) && !seen.has(lower))
|
|
257
|
+
{
|
|
258
|
+
seen.add(lower);
|
|
259
|
+
suggestions.push(word);
|
|
260
|
+
if (suggestions.length >= limit) return suggestions;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return suggestions;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* In-memory full-text search using regex matching and scoring.
|
|
271
|
+
* Used as fallback for memory/json adapters.
|
|
272
|
+
* @param {string} query - Search query.
|
|
273
|
+
* @param {object} options - Search options.
|
|
274
|
+
* @returns {Promise<Array>} Matching rows with optional _rank.
|
|
275
|
+
* @private
|
|
276
|
+
*/
|
|
277
|
+
async _memorySearch(query, options = {})
|
|
278
|
+
{
|
|
279
|
+
const { rank = false, limit, offset = 0, where = {} } = options;
|
|
280
|
+
|
|
281
|
+
// Tokenise query into words
|
|
282
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
283
|
+
if (!tokens.length) return [];
|
|
284
|
+
|
|
285
|
+
// Build regex for each token (escape special chars)
|
|
286
|
+
const patterns = tokens.map(t =>
|
|
287
|
+
{
|
|
288
|
+
const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
289
|
+
return new RegExp(escaped, 'i');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Get all records, with optional WHERE conditions
|
|
293
|
+
let q = this._model.query();
|
|
294
|
+
if (Object.keys(where).length) q = q.where(where);
|
|
295
|
+
const allRows = await q.exec();
|
|
296
|
+
|
|
297
|
+
// Score each row
|
|
298
|
+
const scored = [];
|
|
299
|
+
for (const row of allRows)
|
|
300
|
+
{
|
|
301
|
+
let score = 0;
|
|
302
|
+
let matched = false;
|
|
303
|
+
|
|
304
|
+
for (const field of this._fields)
|
|
305
|
+
{
|
|
306
|
+
const val = row[field];
|
|
307
|
+
if (!val) continue;
|
|
308
|
+
const text = String(val).toLowerCase();
|
|
309
|
+
const weight = this._getWeight(field);
|
|
310
|
+
|
|
311
|
+
for (const pattern of patterns)
|
|
312
|
+
{
|
|
313
|
+
const matches = text.match(new RegExp(pattern.source, 'gi'));
|
|
314
|
+
if (matches)
|
|
315
|
+
{
|
|
316
|
+
matched = true;
|
|
317
|
+
score += matches.length * weight;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (matched)
|
|
323
|
+
{
|
|
324
|
+
const data = row.toJSON ? row.toJSON() : { ...row };
|
|
325
|
+
if (rank) data._rank = score;
|
|
326
|
+
scored.push({ data, score });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Sort by score (descending)
|
|
331
|
+
scored.sort((a, b) => b.score - a.score);
|
|
332
|
+
|
|
333
|
+
// Apply pagination
|
|
334
|
+
let results = scored.map(s => s.data);
|
|
335
|
+
if (offset) results = results.slice(offset);
|
|
336
|
+
if (limit) results = results.slice(0, limit);
|
|
337
|
+
|
|
338
|
+
return results;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get numeric weight for a field.
|
|
343
|
+
* @param {string} field - Field name.
|
|
344
|
+
* @returns {number} Numeric weight multiplier.
|
|
345
|
+
* @private
|
|
346
|
+
*/
|
|
347
|
+
_getWeight(field)
|
|
348
|
+
{
|
|
349
|
+
const w = this._weights[field];
|
|
350
|
+
if (!w) return 1;
|
|
351
|
+
// PostgreSQL-style weights: A=4, B=3, C=2, D=1
|
|
352
|
+
if (typeof w === 'string')
|
|
353
|
+
{
|
|
354
|
+
const map = { A: 4, B: 3, C: 2, D: 1 };
|
|
355
|
+
return map[w.toUpperCase()] || 1;
|
|
356
|
+
}
|
|
357
|
+
return Number(w) || 1;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Detect the adapter type from its constructor or methods.
|
|
362
|
+
* @returns {string} Adapter type identifier.
|
|
363
|
+
* @private
|
|
364
|
+
*/
|
|
365
|
+
_detectAdapterType()
|
|
366
|
+
{
|
|
367
|
+
const adapter = this._adapter;
|
|
368
|
+
if (!adapter) return 'memory';
|
|
369
|
+
const name = adapter.constructor.name.toLowerCase();
|
|
370
|
+
if (name.includes('postgres')) return 'postgres';
|
|
371
|
+
if (name.includes('mysql')) return 'mysql';
|
|
372
|
+
if (name.includes('sqlite')) return 'sqlite';
|
|
373
|
+
if (name.includes('mongo')) return 'mongo';
|
|
374
|
+
if (name.includes('redis')) return 'redis';
|
|
375
|
+
if (name.includes('json')) return 'json';
|
|
376
|
+
return 'memory';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { FullTextSearch };
|