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.
- package/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
702
|
+
await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
617
703
|
}
|
|
618
704
|
|
|
619
705
|
for (const record of data) {
|
package/core/orm/repository.js
CHANGED
|
@@ -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>}
|