webspresso 0.0.98 → 0.0.99

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.
Files changed (56) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/AUTH.md +169 -0
  4. package/.agents/ORM.md +88 -4
  5. package/.agents/PLUGINS.md +73 -5
  6. package/.agents/QUEUE.md +84 -0
  7. package/.agents/ROUTING.md +127 -7
  8. package/AGENTS.md +1 -0
  9. package/core/auth/index.js +60 -0
  10. package/core/auth/manager.js +134 -0
  11. package/core/auth/middleware.js +88 -0
  12. package/core/auth/oauth.js +517 -0
  13. package/core/auth/qrcode.js +499 -0
  14. package/core/auth/totp.js +504 -0
  15. package/core/orm/eager-loader.js +144 -10
  16. package/core/orm/index.js +8 -0
  17. package/core/orm/model.js +4 -0
  18. package/core/orm/query-builder.js +90 -4
  19. package/core/orm/repository.js +44 -3
  20. package/core/orm/search.js +213 -0
  21. package/core/queue/cron.js +331 -0
  22. package/core/queue/index.js +8 -1
  23. package/core/queue/manager.js +82 -1
  24. package/core/queue/scheduler.js +359 -0
  25. package/core/sse/index.js +19 -0
  26. package/core/sse/stream.js +430 -0
  27. package/index.d.ts +528 -0
  28. package/index.js +44 -0
  29. package/package.json +1 -1
  30. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  31. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  32. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  34. package/plugins/admin-panel/field-renderers/index.js +2 -0
  35. package/plugins/api-key/crypto.js +101 -0
  36. package/plugins/api-key/index.js +288 -0
  37. package/plugins/api-key/middleware.js +461 -0
  38. package/plugins/api-key/migration-template.js +48 -0
  39. package/plugins/api-key/model.js +50 -0
  40. package/plugins/api-key/scopes.js +125 -0
  41. package/plugins/audit-log/admin-component.js +487 -70
  42. package/plugins/audit-log/api-handlers.js +114 -0
  43. package/plugins/audit-log/index.js +24 -0
  44. package/plugins/file-manager/admin-component.js +20 -4
  45. package/plugins/file-manager/index.js +21 -4
  46. package/plugins/index.js +6 -0
  47. package/plugins/oauth/index.js +338 -0
  48. package/plugins/queue/index.js +4 -0
  49. package/src/api/api-loader.js +1 -0
  50. package/src/discovery/file-route-parser.js +44 -1
  51. package/src/discovery/scan-routes.js +2 -2
  52. package/src/file-router.js +137 -100
  53. package/src/helpers.js +9 -0
  54. package/src/routing/cascading.js +378 -0
  55. package/src/routing/middleware-resolver.js +92 -0
  56. package/src/server.js +6 -0
@@ -1,31 +1,109 @@
1
1
  /**
2
2
  * Webspresso ORM - Eager Loader
3
- * Batch loading algorithm for relations
3
+ * Batch loading algorithm for relations (supports direct & arbitrary nested relations)
4
4
  * @module core/orm/eager-loader
5
5
  */
6
6
 
7
7
  const { resolveRelationModel, getRelationKeys } = require('./model');
8
8
  const { applyScopes, createScopeContext } = require('./scopes');
9
+ const { getJsonColumns, deserializeJsonFields } = require('./json-fields');
9
10
 
10
11
  /**
11
- * Load relations for a set of records using batch queries
12
+ * Parse relation specifications into a grouped structure: Map<topLevelRelation, string[]>
13
+ * Supports dot notation ('author.profile'), arrays (['author', 'comments.author']),
14
+ * and nested relation chains of arbitrary depth.
15
+ *
16
+ * @param {Array<string|Object>} relationNames
17
+ * @returns {Map<string, string[]>}
18
+ */
19
+ function parseRelationTree(relationNames) {
20
+ const tree = new Map();
21
+
22
+ function addPath(top, nested) {
23
+ if (!top || typeof top !== 'string') return;
24
+ const cleanTop = top.trim();
25
+ if (!cleanTop) return;
26
+
27
+ if (!tree.has(cleanTop)) {
28
+ tree.set(cleanTop, []);
29
+ }
30
+ if (nested) {
31
+ if (Array.isArray(nested)) {
32
+ for (const item of nested) {
33
+ if (item && typeof item === 'string') tree.get(cleanTop).push(item.trim());
34
+ }
35
+ } else if (typeof nested === 'string') {
36
+ const cleanNested = nested.trim();
37
+ if (cleanNested) tree.get(cleanTop).push(cleanNested);
38
+ }
39
+ }
40
+ }
41
+
42
+ function processItem(item) {
43
+ if (!item) return;
44
+
45
+ if (typeof item === 'string') {
46
+ const dotIndex = item.indexOf('.');
47
+ if (dotIndex === -1) {
48
+ addPath(item);
49
+ } else {
50
+ const top = item.slice(0, dotIndex);
51
+ const rest = item.slice(dotIndex + 1);
52
+ addPath(top, rest);
53
+ }
54
+ } else if (Array.isArray(item)) {
55
+ for (const sub of item) {
56
+ processItem(sub);
57
+ }
58
+ } else if (typeof item === 'object') {
59
+ for (const [key, value] of Object.entries(item)) {
60
+ if (value === true || value == null) {
61
+ processItem(key);
62
+ } else if (typeof value === 'string' || Array.isArray(value)) {
63
+ const dotIndex = key.indexOf('.');
64
+ if (dotIndex === -1) {
65
+ addPath(key, value);
66
+ } else {
67
+ const top = key.slice(0, dotIndex);
68
+ const rest = key.slice(dotIndex + 1);
69
+ const nestedValues = Array.isArray(value) ? value : [value];
70
+ for (const v of nestedValues) {
71
+ addPath(top, `${rest}.${v}`);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ const list = Array.isArray(relationNames) ? relationNames : [relationNames];
80
+ for (const item of list) {
81
+ processItem(item);
82
+ }
83
+
84
+ return tree;
85
+ }
86
+
87
+ /**
88
+ * Load relations for a set of records using batch queries (supports nested relations)
12
89
  * @param {Object[]} records - Records to load relations for
13
- * @param {string[]} relationNames - Names of relations to load
90
+ * @param {Array<string|Object>} relationNames - Names of relations to load (e.g. ['author.profile', 'comments.author'])
14
91
  * @param {import('./types').ModelDefinition} model - Model definition
15
92
  * @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
16
93
  * @param {import('./types').ScopeContext} [scopeContext] - Scope context
17
94
  * @returns {Promise<Object[]>} Records with relations attached
18
95
  */
19
96
  async function loadRelations(records, relationNames, model, knex, scopeContext) {
20
- if (!records.length || !relationNames.length) {
97
+ if (!records || !records.length || !relationNames || !relationNames.length) {
21
98
  return records;
22
99
  }
23
100
 
24
101
  const context = scopeContext || createScopeContext();
102
+ const tree = parseRelationTree(relationNames);
25
103
 
26
- // Process each relation
27
- for (const relationName of relationNames) {
28
- const relation = model.relations[relationName];
104
+ // Process each relation grouped by top-level
105
+ for (const [relationName, nestedPaths] of tree.entries()) {
106
+ const relation = model.relations?.[relationName];
29
107
  if (!relation) {
30
108
  console.warn(`Relation "${relationName}" not found on model "${model.name}"`);
31
109
  continue;
@@ -43,6 +121,38 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
43
121
  case 'hasOne':
44
122
  await loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, context);
45
123
  break;
124
+ default:
125
+ console.warn(`Unsupported relation type "${relation.type}" for relation "${relationName}" on model "${model.name}"`);
126
+ continue;
127
+ }
128
+
129
+ // Process nested relations if any
130
+ if (nestedPaths.length > 0) {
131
+ const childRecords = [];
132
+ const seenRefs = new Set();
133
+ const len = records.length;
134
+
135
+ for (let i = 0; i < len; i++) {
136
+ const child = records[i][relationName];
137
+ if (!child) continue;
138
+
139
+ if (Array.isArray(child)) {
140
+ for (let j = 0; j < child.length; j++) {
141
+ const item = child[j];
142
+ if (item && typeof item === 'object' && !seenRefs.has(item)) {
143
+ seenRefs.add(item);
144
+ childRecords.push(item);
145
+ }
146
+ }
147
+ } else if (typeof child === 'object' && !seenRefs.has(child)) {
148
+ seenRefs.add(child);
149
+ childRecords.push(child);
150
+ }
151
+ }
152
+
153
+ if (childRecords.length > 0) {
154
+ await loadRelations(childRecords, nestedPaths, relatedModel, knex, context);
155
+ }
46
156
  }
47
157
  }
48
158
 
@@ -116,6 +226,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
116
226
  relatedModel
117
227
  );
118
228
 
229
+ const jsonCols = getJsonColumns(relatedModel);
230
+ if (jsonCols.size > 0) {
231
+ const rLen = relatedRecords.length;
232
+ for (let i = 0; i < rLen; i++) {
233
+ deserializeJsonFields(relatedRecords[i], jsonCols);
234
+ }
235
+ }
236
+
119
237
  const relatedMap = new Map();
120
238
  const rLen = relatedRecords.length;
121
239
  const pk = relatedModel.primaryKey;
@@ -171,6 +289,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
171
289
  relatedModel
172
290
  );
173
291
 
292
+ const jsonCols = getJsonColumns(relatedModel);
293
+ if (jsonCols.size > 0) {
294
+ const rLen = relatedRecords.length;
295
+ for (let i = 0; i < rLen; i++) {
296
+ deserializeJsonFields(relatedRecords[i], jsonCols);
297
+ }
298
+ }
299
+
174
300
  const relatedGroups = new Map();
175
301
  const rLen = relatedRecords.length;
176
302
  for (let i = 0; i < rLen; i++) {
@@ -229,6 +355,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
229
355
  relatedModel
230
356
  );
231
357
 
358
+ const jsonCols = getJsonColumns(relatedModel);
359
+ if (jsonCols.size > 0) {
360
+ const rLen = relatedRecords.length;
361
+ for (let i = 0; i < rLen; i++) {
362
+ deserializeJsonFields(relatedRecords[i], jsonCols);
363
+ }
364
+ }
365
+
232
366
  const relatedMap = new Map();
233
367
  const rLen = relatedRecords.length;
234
368
  for (let i = 0; i < rLen; i++) {
@@ -247,9 +381,9 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
247
381
  }
248
382
 
249
383
  /**
250
- * Load a single relation for a single record
384
+ * Load a single relation (or nested relation path) for a single record
251
385
  * @param {Object} record - Record to load relation for
252
- * @param {string} relationName - Name of relation to load
386
+ * @param {string} relationName - Name or dot-separated path of relation to load (e.g. 'author.profile')
253
387
  * @param {import('./types').ModelDefinition} model - Model definition
254
388
  * @param {import('knex').Knex} knex - Knex instance
255
389
  * @param {import('./types').ScopeContext} [scopeContext] - Scope context
@@ -267,5 +401,5 @@ module.exports = {
267
401
  loadHasMany,
268
402
  loadHasOne,
269
403
  queryInChunks,
404
+ parseRelationTree,
270
405
  };
271
-
package/core/orm/index.js CHANGED
@@ -372,9 +372,17 @@ module.exports = {
372
372
  Hooks,
373
373
  HookCancellationError,
374
374
  createEventContext,
375
+ // Eager loading & nested relations
376
+ loadRelations: require('./eager-loader').loadRelations,
377
+ loadRelation: require('./eager-loader').loadRelation,
378
+ parseRelationTree: require('./eager-loader').parseRelationTree,
375
379
  // Query Complexity & DoS Protection
376
380
  QueryComplexityError: require('./complexity').QueryComplexityError,
377
381
  validateQueryComplexity: require('./complexity').validateQueryComplexity,
378
382
  resolveQueryLimits: require('./complexity').resolveQueryLimits,
379
383
  DEFAULT_QUERY_LIMITS: require('./complexity').DEFAULT_QUERY_LIMITS,
384
+ // Universal Full-Text Search
385
+ buildSearchClause: require('./search').buildSearchClause,
386
+ tokenizeSearchTerm: require('./search').tokenizeSearchTerm,
387
+ detectDialect: require('./search').detectDialect,
380
388
  };
package/core/orm/model.js CHANGED
@@ -33,6 +33,7 @@ function defineModel(options) {
33
33
  hidden = [],
34
34
  queryLimits = {},
35
35
  cache: cacheOption,
36
+ searchable,
36
37
  } = options;
37
38
 
38
39
  // Validate required fields
@@ -131,6 +132,9 @@ function defineModel(options) {
131
132
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
132
133
  },
133
134
  hidden: Array.isArray(hidden) ? hidden : [],
135
+ searchable: Array.isArray(searchable)
136
+ ? searchable
137
+ : (Array.isArray(admin.searchFields) ? admin.searchFields : null),
134
138
  queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
135
139
  hooks: {},
136
140
  cache: cacheOption,
@@ -15,6 +15,7 @@ const {
15
15
  } = require('./json-fields');
16
16
  const { getAmbientTransaction } = require('./transaction');
17
17
  const { validateQueryComplexity } = require('./complexity');
18
+ const { buildSearchClause } = require('./search');
18
19
 
19
20
  /**
20
21
  * Create a new query builder
@@ -232,6 +233,70 @@ class QueryBuilder {
232
233
  return this;
233
234
  }
234
235
 
236
+ /**
237
+ * Add a raw ORDER BY clause
238
+ * @param {string} sql - Raw SQL expression
239
+ * @param {Array} [bindings=[]] - Parameter bindings
240
+ * @returns {this}
241
+ */
242
+ orderByRaw(sql, bindings = []) {
243
+ this.state.orderBys.push({ raw: true, sql, bindings });
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Full-Text / Keyword Search across columns
249
+ * Supports SQLite, PostgreSQL, and MySQL with dialect-aware FTS and tokenized LIKE fallback
250
+ * @param {string} term - Search query string
251
+ * @param {string|string[]} [columns] - Columns to search (defaults to model.searchable)
252
+ * @param {Object} [options] - Search options
253
+ * @param {'auto'|'fts'|'like'} [options.mode='auto'] - Search mode
254
+ * @param {string} [options.language='english'] - PostgreSQL text search language
255
+ * @param {boolean} [options.matchAllWords=true] - Require all words to match (AND)
256
+ * @param {boolean} [options.exact=false] - Match exact phrase
257
+ * @param {boolean} [options.rank=false] - Order by relevance rank (Postgres)
258
+ * @param {string} [options.ftsTable] - Separate SQLite FTS virtual table name
259
+ * @returns {this}
260
+ */
261
+ search(term, columns, options = {}) {
262
+ const activeKnex = this.getActiveKnex();
263
+ const clause = buildSearchClause({
264
+ knex: activeKnex,
265
+ model: this.model,
266
+ term,
267
+ columns,
268
+ options,
269
+ });
270
+
271
+ if (!clause) {
272
+ return this;
273
+ }
274
+
275
+ this.state.wheres.push({
276
+ raw: true,
277
+ sql: clause.sql,
278
+ bindings: clause.bindings,
279
+ boolean: clause.boolean || 'and',
280
+ });
281
+
282
+ if (clause.rankClause) {
283
+ this.orderByRaw(clause.rankClause.sql, clause.rankClause.bindings);
284
+ }
285
+
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * Add an OR Full-Text / Keyword Search clause
291
+ * @param {string} term - Search query string
292
+ * @param {string|string[]} [columns] - Columns to search
293
+ * @param {Object} [options] - Search options
294
+ * @returns {this}
295
+ */
296
+ orSearch(term, columns, options = {}) {
297
+ return this.search(term, columns, { ...options, boolean: 'or' });
298
+ }
299
+
235
300
  /**
236
301
  * Set LIMIT
237
302
  * @param {number} limit - Limit value
@@ -265,6 +330,15 @@ class QueryBuilder {
265
330
  return this;
266
331
  }
267
332
 
333
+ /**
334
+ * Add relations to eager load (alias for with)
335
+ * @param {...string} relations - Relation names or nested paths (e.g. 'author.profile')
336
+ * @returns {this}
337
+ */
338
+ include(...relations) {
339
+ return this.with(...relations);
340
+ }
341
+
268
342
  /**
269
343
  * Include soft-deleted records
270
344
  * @returns {this}
@@ -376,7 +450,11 @@ class QueryBuilder {
376
450
 
377
451
  // Apply order by
378
452
  for (const orderBy of this.state.orderBys) {
379
- qb = qb.orderBy(orderBy.column, orderBy.direction);
453
+ if (orderBy.raw) {
454
+ qb = qb.orderByRaw(orderBy.sql, orderBy.bindings);
455
+ } else {
456
+ qb = qb.orderBy(orderBy.column, orderBy.direction);
457
+ }
380
458
  }
381
459
 
382
460
  if (includeLimitOffset) {
@@ -416,7 +494,7 @@ class QueryBuilder {
416
494
 
417
495
  const withs = self.getWiths();
418
496
  if (withs.length > 0) {
419
- await loadRelations([result], ensureArray(withs), self.model, self.knex, self.scopeContext);
497
+ await loadRelations([result], ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
420
498
  }
421
499
 
422
500
  ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
@@ -482,7 +560,7 @@ class QueryBuilder {
482
560
 
483
561
  const withs = self.getWiths();
484
562
  if (withs.length > 0 && results.length > 0) {
485
- await loadRelations(results, ensureArray(withs), self.model, self.knex, self.scopeContext);
563
+ await loadRelations(results, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
486
564
  }
487
565
 
488
566
  for (const record of results) {
@@ -527,6 +605,14 @@ class QueryBuilder {
527
605
  return this.list();
528
606
  }
529
607
 
608
+ /**
609
+ * Alias for list()
610
+ * @returns {Promise<Object[]>}
611
+ */
612
+ async find() {
613
+ return this.list();
614
+ }
615
+
530
616
  /**
531
617
  * Execute query and return count
532
618
  * @returns {Promise<number>}
@@ -613,7 +699,7 @@ class QueryBuilder {
613
699
 
614
700
  const withs = self.getWiths();
615
701
  if (withs.length > 0 && data.length > 0) {
616
- await loadRelations(data, ensureArray(withs), self.model, self.knex, self.scopeContext);
702
+ await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
617
703
  }
618
704
 
619
705
  for (const record of data) {
@@ -73,7 +73,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
73
73
  * @returns {Promise<Object|null>}
74
74
  */
75
75
  async function findById(id, options = {}, emitEvents = true) {
76
- const { with: withs = [], select } = options;
76
+ const { with: withs = options.include || [], select } = options;
77
77
  const shouldEmit = emitEvents && (
78
78
  ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
79
79
  ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
@@ -144,7 +144,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
144
144
  * @returns {Promise<Object|null>}
145
145
  */
146
146
  async function findOne(conditions, options = {}) {
147
- const { with: withs = [], select } = options;
147
+ const { with: withs = options.include || [], select } = options;
148
148
  const shouldEmit = (
149
149
  ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
150
150
  ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
@@ -230,7 +230,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
230
230
  * @returns {Promise<Object[]>}
231
231
  */
232
232
  async function findAll(options = {}) {
233
- const { with: withs = [], select } = options;
233
+ const { with: withs = options.include || [], select } = options;
234
234
  const shouldEmit = (
235
235
  ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
236
236
  ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
@@ -619,10 +619,49 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
619
619
  return query().with(...relations);
620
620
  }
621
621
 
622
+ /**
623
+ * Find records matching conditions (with optional relations to eager load)
624
+ * @param {Object} [conditions={}] - WHERE conditions
625
+ * @param {import('./types').FindOptions} [options={}] - Find options
626
+ * @returns {Promise<Object[]>}
627
+ */
628
+ async function find(conditions = {}, options = {}) {
629
+ if (!conditions || Object.keys(conditions).length === 0) {
630
+ return findAll(options);
631
+ }
632
+ const withs = options.include || options.with || [];
633
+ let qb = query().where(conditions);
634
+ if (withs.length > 0) {
635
+ qb = qb.with(withs);
636
+ }
637
+ if (options.select && options.select.length > 0) {
638
+ qb = qb.select(options.select);
639
+ }
640
+ if (options.limit !== undefined) {
641
+ qb = qb.limit(options.limit);
642
+ }
643
+ if (options.offset !== undefined) {
644
+ qb = qb.offset(options.offset);
645
+ }
646
+ return qb.list();
647
+ }
648
+
649
+ /**
650
+ * Search records using Full-Text / Keyword Search
651
+ * @param {string} term - Search query
652
+ * @param {string|string[]} [columns] - Columns to search
653
+ * @param {Object} [options] - Search options
654
+ * @returns {import('./query-builder').QueryBuilder}
655
+ */
656
+ function search(term, columns, options) {
657
+ return query().search(term, columns, options);
658
+ }
659
+
622
660
  return {
623
661
  findById,
624
662
  findOne,
625
663
  findAll,
664
+ find,
626
665
  create,
627
666
  createMany,
628
667
  update,
@@ -631,10 +670,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
631
670
  forceDelete,
632
671
  restore,
633
672
  query,
673
+ search,
634
674
  raw,
635
675
  count,
636
676
  exists,
637
677
  with: withRelations,
678
+ include: withRelations,
638
679
  // Expose model for introspection
639
680
  model,
640
681
  };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Webspresso ORM - Universal Full-Text Search (FTS)
3
+ * Database-agnostic full-text search abstraction for SQLite, PostgreSQL, and MySQL
4
+ * @module core/orm/search
5
+ */
6
+
7
+ const { ensureArray } = require('./utils');
8
+
9
+ /**
10
+ * Detect database dialect from Knex instance
11
+ * @param {import('knex').Knex} knex
12
+ * @returns {'sqlite'|'postgres'|'mysql'|'generic'}
13
+ */
14
+ function detectDialect(knex) {
15
+ const client = knex?.client?.config?.client || knex?.client?.dialect || '';
16
+ const s = String(client).toLowerCase();
17
+ if (s.includes('sqlite')) return 'sqlite';
18
+ if (s.includes('pg') || s.includes('postgres')) return 'postgres';
19
+ if (s.includes('mysql') || s.includes('maria')) return 'mysql';
20
+ return 'generic';
21
+ }
22
+
23
+ /**
24
+ * Tokenize search term into clean tokens, supporting quoted exact phrases
25
+ * e.g. 'apple "macbook pro" laptop' -> ['apple', 'macbook pro', 'laptop']
26
+ * @param {string} term
27
+ * @returns {string[]}
28
+ */
29
+ function tokenizeSearchTerm(term) {
30
+ if (!term || typeof term !== 'string') return [];
31
+ const matches = term.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
32
+ return matches
33
+ .map((t) => t.replace(/^"|"$/g, '').trim())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ /**
38
+ * Normalize and validate search columns for a model
39
+ * @param {Object} model
40
+ * @param {string|string[]} [columns]
41
+ * @returns {string[]}
42
+ */
43
+ function normalizeSearchColumns(model, columns) {
44
+ if (columns != null && (Array.isArray(columns) ? columns.length > 0 : Boolean(columns))) {
45
+ return ensureArray(columns);
46
+ }
47
+
48
+ // Fallback to model searchable fields if configured
49
+ if (Array.isArray(model?.searchable) && model.searchable.length > 0) {
50
+ return model.searchable;
51
+ }
52
+ if (Array.isArray(model?.admin?.searchFields) && model.admin.searchFields.length > 0) {
53
+ return model.admin.searchFields;
54
+ }
55
+
56
+ throw new Error(
57
+ `No searchable columns specified for model "${model?.name || 'Unknown'}". Pass columns to .search(term, columns) or define searchable: [...] on the model.`
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Build SQL search clause and parameter bindings
63
+ * @param {Object} params
64
+ * @param {import('knex').Knex} params.knex - Knex instance
65
+ * @param {Object} params.model - Model definition
66
+ * @param {string} params.term - Search query
67
+ * @param {string|string[]} [params.columns] - Columns to search
68
+ * @param {Object} [params.options] - Search options
69
+ * @param {'auto'|'fts'|'like'} [params.options.mode='auto'] - Search mode
70
+ * @param {string} [params.options.language='english'] - PostgreSQL text search configuration
71
+ * @param {boolean} [params.options.matchAllWords=true] - Require all words to match (AND vs OR)
72
+ * @param {boolean} [params.options.exact=false] - Treat entire term as exact substring
73
+ * @param {boolean} [params.options.rank=false] - Order by search relevance rank (Postgres)
74
+ * @param {string} [params.options.ftsTable] - Separate SQLite FTS virtual table name
75
+ * @param {'and'|'or'} [params.options.boolean='and'] - Where condition boolean operator
76
+ * @returns {{ raw: true, sql: string, bindings: any[], boolean: 'and'|'or', rankClause: Object|null }|null}
77
+ */
78
+ function buildSearchClause({ knex, model, term, columns, options = {} }) {
79
+ if (!term || typeof term !== 'string' || !term.trim()) {
80
+ return null;
81
+ }
82
+
83
+ const cleanTerm = term.trim();
84
+ const searchColumns = normalizeSearchColumns(model, columns);
85
+ const dialect = detectDialect(knex);
86
+ const mode = options.mode || 'auto';
87
+ const language = options.language || 'english';
88
+ const matchAllWords = options.matchAllWords !== false;
89
+ const exact = options.exact === true;
90
+ const boolean = options.boolean === 'or' ? 'or' : 'and';
91
+ const rank = options.rank === true;
92
+
93
+ // 1. PostgreSQL Full-Text Search (when mode is 'fts' or 'auto' on postgres)
94
+ if (dialect === 'postgres' && (mode === 'fts' || mode === 'auto')) {
95
+ const colsSql = searchColumns
96
+ .map((c) => `COALESCE(${knex.ref(c)}::text, '')`)
97
+ .join(" || ' ' || ");
98
+
99
+ const queryFn = exact || cleanTerm.includes('"')
100
+ ? 'websearch_to_tsquery'
101
+ : 'plainto_tsquery';
102
+
103
+ const sql = `to_tsvector(?, ${colsSql}) @@ ${queryFn}(?, ?)`;
104
+ const bindings = [language, language, cleanTerm];
105
+
106
+ let rankClause = null;
107
+ if (rank) {
108
+ rankClause = {
109
+ sql: `ts_rank(to_tsvector(?, ${colsSql}), ${queryFn}(?, ?)) DESC`,
110
+ bindings: [language, language, cleanTerm],
111
+ };
112
+ }
113
+
114
+ return {
115
+ raw: true,
116
+ sql,
117
+ bindings,
118
+ boolean,
119
+ rankClause,
120
+ };
121
+ }
122
+
123
+ // 2. SQLite Native FTS5 (when mode is 'fts' on sqlite)
124
+ if (dialect === 'sqlite' && mode === 'fts') {
125
+ if (options.ftsTable) {
126
+ const ftsRef = knex.ref(options.ftsTable).toString();
127
+ const sql = `rowid IN (SELECT rowid FROM ${ftsRef} WHERE ${ftsRef} MATCH ?)`;
128
+ return {
129
+ raw: true,
130
+ sql,
131
+ bindings: [cleanTerm],
132
+ boolean,
133
+ rankClause: null,
134
+ };
135
+ }
136
+
137
+ const tableRef = knex.ref(model.table).toString();
138
+ const sql = `${tableRef} MATCH ?`;
139
+ return {
140
+ raw: true,
141
+ sql,
142
+ bindings: [cleanTerm],
143
+ boolean,
144
+ rankClause: null,
145
+ };
146
+ }
147
+
148
+ // 3. MySQL FullText (when mode is 'fts' on mysql)
149
+ if (dialect === 'mysql' && mode === 'fts') {
150
+ const colsList = searchColumns.map((c) => knex.ref(c).toString()).join(', ');
151
+ const sql = `MATCH(${colsList}) AGAINST(? IN BOOLEAN MODE)`;
152
+ return {
153
+ raw: true,
154
+ sql,
155
+ bindings: [cleanTerm],
156
+ boolean,
157
+ rankClause: null,
158
+ };
159
+ }
160
+
161
+ // 4. Universal Tokenized LIKE Search (default for SQLite, MySQL, and mode: 'like')
162
+ if (exact) {
163
+ const pattern = `%${cleanTerm}%`;
164
+ const bindings = [];
165
+ const colClauses = searchColumns.map((c) => {
166
+ bindings.push(pattern);
167
+ return `LOWER(${knex.ref(c)}) LIKE LOWER(?)`;
168
+ });
169
+
170
+ return {
171
+ raw: true,
172
+ sql: `(${colClauses.join(' OR ')})`,
173
+ bindings,
174
+ boolean,
175
+ rankClause: null,
176
+ };
177
+ }
178
+
179
+ const tokens = tokenizeSearchTerm(cleanTerm);
180
+ if (tokens.length === 0) {
181
+ return null;
182
+ }
183
+
184
+ const wordClauses = [];
185
+ const bindings = [];
186
+
187
+ for (const token of tokens) {
188
+ const pattern = `%${token}%`;
189
+ const colClauses = searchColumns.map((c) => {
190
+ bindings.push(pattern);
191
+ return `LOWER(${knex.ref(c)}) LIKE LOWER(?)`;
192
+ });
193
+ wordClauses.push(`(${colClauses.join(' OR ')})`);
194
+ }
195
+
196
+ const joinOp = matchAllWords ? ' AND ' : ' OR ';
197
+ const sql = `(${wordClauses.join(joinOp)})`;
198
+
199
+ return {
200
+ raw: true,
201
+ sql,
202
+ bindings,
203
+ boolean,
204
+ rankClause: null,
205
+ };
206
+ }
207
+
208
+ module.exports = {
209
+ detectDialect,
210
+ tokenizeSearchTerm,
211
+ normalizeSearchColumns,
212
+ buildSearchClause,
213
+ };