webspresso 0.0.97 → 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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
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
@@ -107,6 +108,22 @@ function defineModel(options) {
107
108
  sortable: admin.sortable,
108
109
  sortableColumns: admin.sortableColumns,
109
110
  columns: admin.columns,
111
+ listColumns: Array.isArray(admin.listColumns) ? admin.listColumns : null,
112
+ searchFields: Array.isArray(admin.searchFields) ? admin.searchFields : null,
113
+ displayField: typeof admin.displayField === 'string' ? admin.displayField : null,
114
+ readOnly: admin.readOnly === true,
115
+ permissions: {
116
+ create: admin.permissions?.create !== false,
117
+ edit: admin.permissions?.edit !== false,
118
+ delete: admin.permissions?.delete !== false,
119
+ },
120
+ defaultSort: typeof admin.defaultSort === 'object' && admin.defaultSort !== null ? admin.defaultSort : null,
121
+ group: typeof admin.group === 'string' ? admin.group : (typeof admin.menuGroup === 'string' ? admin.menuGroup : null),
122
+ menuOrder: typeof admin.menuOrder === 'number' ? admin.menuOrder : 10,
123
+ actions: Array.isArray(admin.actions) ? admin.actions : null,
124
+ bulkActions: Array.isArray(admin.bulkActions) ? admin.bulkActions : null,
125
+ hooks: typeof admin.hooks === 'object' && admin.hooks !== null ? admin.hooks : null,
126
+ relations: typeof admin.relations === 'object' && admin.relations !== null ? admin.relations : null,
110
127
  },
111
128
  rest: {
112
129
  enabled: rest.enabled === true,
@@ -115,6 +132,9 @@ function defineModel(options) {
115
132
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
116
133
  },
117
134
  hidden: Array.isArray(hidden) ? hidden : [],
135
+ searchable: Array.isArray(searchable)
136
+ ? searchable
137
+ : (Array.isArray(admin.searchFields) ? admin.searchFields : null),
118
138
  queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
119
139
  hooks: {},
120
140
  cache: cacheOption,
@@ -152,13 +172,36 @@ function registerModelHooks(modelName, hooks) {
152
172
  }
153
173
  }
154
174
 
175
+ /**
176
+ * Extract model name string from string, String object, or model definition/context
177
+ * @param {string|Object} nameOrModel - Model name or object
178
+ * @returns {string}
179
+ */
180
+ function resolveModelName(nameOrModel) {
181
+ if (typeof nameOrModel === 'string') {
182
+ return nameOrModel;
183
+ }
184
+ if (nameOrModel && typeof nameOrModel === 'object') {
185
+ if (typeof nameOrModel.name === 'string') return nameOrModel.name;
186
+ if (typeof nameOrModel.modelName === 'string') return nameOrModel.modelName;
187
+ if (typeof nameOrModel.model === 'string') return nameOrModel.model;
188
+ if (typeof nameOrModel.valueOf === 'function') {
189
+ const val = nameOrModel.valueOf();
190
+ if (typeof val === 'string') return val;
191
+ }
192
+ return String(nameOrModel);
193
+ }
194
+ return nameOrModel != null ? String(nameOrModel) : '';
195
+ }
196
+
155
197
  /**
156
198
  * Get a model by name
157
- * @param {string} name - Model name
199
+ * @param {string|Object} name - Model name
158
200
  * @returns {import('./types').ModelDefinition|undefined}
159
201
  */
160
202
  function getModel(name) {
161
- return modelRegistry.get(name);
203
+ const modelName = resolveModelName(name);
204
+ return modelRegistry.get(modelName);
162
205
  }
163
206
 
164
207
  /**
@@ -171,11 +214,12 @@ function getAllModels() {
171
214
 
172
215
  /**
173
216
  * Check if a model exists
174
- * @param {string} name - Model name
217
+ * @param {string|Object} name - Model name
175
218
  * @returns {boolean}
176
219
  */
177
220
  function hasModel(name) {
178
- return modelRegistry.has(name);
221
+ const modelName = resolveModelName(name);
222
+ return modelRegistry.has(modelName);
179
223
  }
180
224
 
181
225
  /**
@@ -187,11 +231,12 @@ function clearRegistry() {
187
231
 
188
232
  /**
189
233
  * Unregister a model by name
190
- * @param {string} name - Model name
234
+ * @param {string|Object} name - Model name
191
235
  * @returns {boolean} Whether the model was removed
192
236
  */
193
237
  function unregisterModel(name) {
194
- return modelRegistry.delete(name);
238
+ const modelName = resolveModelName(name);
239
+ return modelRegistry.delete(modelName);
195
240
  }
196
241
 
197
242
  /**
@@ -201,7 +246,7 @@ function unregisterModel(name) {
201
246
  */
202
247
  function resolveRelationModel(relation) {
203
248
  let model = typeof relation.model === 'function' ? relation.model() : relation.model;
204
- if (typeof model === 'string') {
249
+ if (typeof model === 'string' || (model && typeof model === 'object' && !model.table)) {
205
250
  model = getModel(model);
206
251
  }
207
252
  if (!model || !model.name) {
@@ -239,6 +284,7 @@ module.exports = {
239
284
  hasModel,
240
285
  clearRegistry,
241
286
  unregisterModel,
287
+ resolveModelName,
242
288
  resolveRelationModel,
243
289
  getRelationKeys,
244
290
  // Export registry for testing
@@ -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
+ };
@@ -271,6 +271,100 @@ class DatabaseQueueAdapter {
271
271
  return stats;
272
272
  }
273
273
 
274
+ /**
275
+ * Delete completed or failed jobs older than a cutoff date
276
+ * @param {Object} [options={}]
277
+ * @param {string|string[]} [options.status=['completed']] - Job statuses to prune
278
+ * @param {Date|string|number} [options.olderThan] - Explicit cutoff date
279
+ * @param {number} [options.days=7] - Retention in days (used if olderThan not provided)
280
+ * @param {number} [options.retentionDays] - Alias for days
281
+ * @param {number} [options.limit] - Max number of records to delete
282
+ * @returns {Promise<number>} Number of deleted jobs
283
+ */
284
+ async clean(options = {}) {
285
+ await this.ensureTable();
286
+
287
+ let olderThan;
288
+ if (options.olderThan !== undefined) {
289
+ olderThan = options.olderThan instanceof Date
290
+ ? options.olderThan
291
+ : new Date(options.olderThan);
292
+
293
+ if (Number.isNaN(olderThan.getTime())) {
294
+ throw new Error('clean: invalid olderThan date');
295
+ }
296
+ } else {
297
+ const rawDays = options.days !== undefined
298
+ ? options.days
299
+ : (options.retentionDays !== undefined ? options.retentionDays : 7);
300
+ const days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
301
+ if (Number.isNaN(days) || days < 0) {
302
+ throw new Error('clean: days must be a non-negative number');
303
+ }
304
+ olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
305
+ }
306
+
307
+ let statuses = ['completed'];
308
+ if (options.status) {
309
+ if (Array.isArray(options.status)) {
310
+ statuses = options.status;
311
+ } else if (typeof options.status === 'string') {
312
+ if (options.status === '*' || options.status === 'all') {
313
+ statuses = ['completed', 'failed', 'pending'];
314
+ } else {
315
+ statuses = options.status.split(',').map((s) => s.trim()).filter(Boolean);
316
+ }
317
+ }
318
+ }
319
+
320
+ const client = this.knex?.client?.config?.client;
321
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
322
+
323
+ let query = this.knex(this.tableName);
324
+
325
+ if (statuses.length === 1) {
326
+ query = query.where('status', statuses[0]);
327
+ } else if (statuses.length > 1) {
328
+ query = query.whereIn('status', statuses);
329
+ }
330
+
331
+ if (isSqlite) {
332
+ const iso = olderThan.toISOString();
333
+ const cutoffMs = olderThan.getTime();
334
+ query = query.where((builder) => {
335
+ builder
336
+ .whereRaw(
337
+ "((typeof(updated_at) = 'integer' and updated_at < ?) or (typeof(updated_at) = 'text' and updated_at < ?))",
338
+ [cutoffMs, iso]
339
+ )
340
+ .orWhere((b2) => {
341
+ b2.whereNull('updated_at').andWhereRaw(
342
+ "((typeof(created_at) = 'integer' and created_at < ?) or (typeof(created_at) = 'text' and created_at < ?))",
343
+ [cutoffMs, iso]
344
+ );
345
+ });
346
+ });
347
+ } else {
348
+ query = query.where((builder) => {
349
+ builder.where('updated_at', '<', olderThan)
350
+ .orWhere((b2) => {
351
+ b2.whereNull('updated_at').andWhere('created_at', '<', olderThan);
352
+ });
353
+ });
354
+ }
355
+
356
+ if (typeof options.limit === 'number' && options.limit > 0) {
357
+ const rows = await query.clone().select('id').limit(options.limit);
358
+ if (rows.length === 0) return 0;
359
+ const ids = rows.map((r) => r.id);
360
+ const deleted = await this.knex(this.tableName).whereIn('id', ids).del();
361
+ return Number(deleted) || 0;
362
+ }
363
+
364
+ const deleted = await query.del();
365
+ return Number(deleted) || 0;
366
+ }
367
+
274
368
  /**
275
369
  * Clear all jobs from table
276
370
  * @returns {Promise<void>}