webspresso 0.0.98 → 0.0.100
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 +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- 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/modules/admin-users.js +1 -1
- 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 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/core/orm/repository.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
const { createQueryBuilder } = require('./query-builder');
|
|
8
8
|
const { loadRelations } = require('./eager-loader');
|
|
9
|
+
const { resolveRelationModel, isCascadeEnabled } = require('./model');
|
|
9
10
|
const {
|
|
10
11
|
applyInsertModifiers,
|
|
11
12
|
applyUpdateModifiers,
|
|
12
13
|
getSoftDeleteData,
|
|
14
|
+
getRestoreData,
|
|
13
15
|
createScopeContext,
|
|
14
16
|
applyScopes,
|
|
15
17
|
} = require('./scopes');
|
|
@@ -73,7 +75,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
73
75
|
* @returns {Promise<Object|null>}
|
|
74
76
|
*/
|
|
75
77
|
async function findById(id, options = {}, emitEvents = true) {
|
|
76
|
-
const { with: withs = [], select } = options;
|
|
78
|
+
const { with: withs = options.include || [], select } = options;
|
|
77
79
|
const shouldEmit = emitEvents && (
|
|
78
80
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
79
81
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -144,7 +146,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
144
146
|
* @returns {Promise<Object|null>}
|
|
145
147
|
*/
|
|
146
148
|
async function findOne(conditions, options = {}) {
|
|
147
|
-
const { with: withs = [], select } = options;
|
|
149
|
+
const { with: withs = options.include || [], select } = options;
|
|
148
150
|
const shouldEmit = (
|
|
149
151
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
150
152
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -230,7 +232,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
230
232
|
* @returns {Promise<Object[]>}
|
|
231
233
|
*/
|
|
232
234
|
async function findAll(options = {}) {
|
|
233
|
-
const { with: withs = [], select } = options;
|
|
235
|
+
const { with: withs = options.include || [], select } = options;
|
|
234
236
|
const shouldEmit = (
|
|
235
237
|
ModelEvents.hasListeners(model.name, Hooks.BEFORE_FIND) ||
|
|
236
238
|
ModelEvents.hasListeners(model.name, Hooks.AFTER_FIND)
|
|
@@ -458,14 +460,236 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
458
460
|
}
|
|
459
461
|
|
|
460
462
|
/**
|
|
461
|
-
*
|
|
463
|
+
* Cascade soft-delete child records for cascading relations
|
|
464
|
+
* @param {import('./types').ModelDefinition} currentModel
|
|
465
|
+
* @param {Object} record
|
|
466
|
+
* @param {Date} deletedAt
|
|
467
|
+
* @param {Object} [options={}]
|
|
468
|
+
* @param {Set<string>} [visited=new Set()]
|
|
469
|
+
*/
|
|
470
|
+
async function cascadeSoftDelete(currentModel, record, deletedAt, options = {}, visited = new Set()) {
|
|
471
|
+
const pkVal = record[currentModel.primaryKey];
|
|
472
|
+
if (pkVal === undefined || pkVal === null) return;
|
|
473
|
+
const visitKey = `${currentModel.name}:${pkVal}`;
|
|
474
|
+
if (visited.has(visitKey)) return;
|
|
475
|
+
visited.add(visitKey);
|
|
476
|
+
|
|
477
|
+
const activeKnex = getActiveKnex();
|
|
478
|
+
|
|
479
|
+
for (const [relationName, relation] of Object.entries(currentModel.relations || {})) {
|
|
480
|
+
if (!['hasMany', 'hasOne'].includes(relation.type)) continue;
|
|
481
|
+
if (!isCascadeEnabled(relation, 'delete')) continue;
|
|
482
|
+
|
|
483
|
+
let relatedModel;
|
|
484
|
+
try {
|
|
485
|
+
relatedModel = resolveRelationModel(relation);
|
|
486
|
+
} catch {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (!relatedModel || !relatedModel.scopes.softDelete) continue;
|
|
490
|
+
|
|
491
|
+
const localKey = relation.localKey || currentModel.primaryKey;
|
|
492
|
+
const foreignKey = relation.foreignKey;
|
|
493
|
+
const parentVal = record[localKey];
|
|
494
|
+
if (parentVal === undefined || parentVal === null) continue;
|
|
495
|
+
|
|
496
|
+
let childQuery = activeKnex(relatedModel.table)
|
|
497
|
+
.where(foreignKey, parentVal)
|
|
498
|
+
.whereNull('deleted_at');
|
|
499
|
+
|
|
500
|
+
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
501
|
+
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const children = await childQuery.select('*');
|
|
505
|
+
if (!children || children.length === 0) continue;
|
|
506
|
+
|
|
507
|
+
const childIds = children.map(c => c[relatedModel.primaryKey]);
|
|
508
|
+
|
|
509
|
+
await activeKnex(relatedModel.table)
|
|
510
|
+
.whereIn(relatedModel.primaryKey, childIds)
|
|
511
|
+
.update(getSoftDeleteData(deletedAt));
|
|
512
|
+
|
|
513
|
+
if (cacheLayer) {
|
|
514
|
+
cacheLayer.invalidateModelAll(relatedModel);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const hasDeeper = Object.values(relatedModel.relations || {}).some(
|
|
518
|
+
r => ['hasMany', 'hasOne'].includes(r.type) && isCascadeEnabled(r, 'delete')
|
|
519
|
+
);
|
|
520
|
+
if (hasDeeper) {
|
|
521
|
+
for (const child of children) {
|
|
522
|
+
await cascadeSoftDelete(relatedModel, child, deletedAt, options, visited);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (ModelEvents.hasListeners(relatedModel.name, Hooks.AFTER_DELETE)) {
|
|
527
|
+
for (const child of children) {
|
|
528
|
+
const childCtx = createEventContext(relatedModel.name, 'delete', getHookTrx());
|
|
529
|
+
ModelEvents.emit(relatedModel.name, Hooks.AFTER_DELETE, child, childCtx);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Cascade hard-delete child records for cascading relations
|
|
537
|
+
* @param {import('./types').ModelDefinition} currentModel
|
|
538
|
+
* @param {Object} record
|
|
539
|
+
* @param {Object} [options={}]
|
|
540
|
+
* @param {Set<string>} [visited=new Set()]
|
|
541
|
+
*/
|
|
542
|
+
async function cascadeHardDelete(currentModel, record, options = {}, visited = new Set()) {
|
|
543
|
+
const pkVal = record[currentModel.primaryKey];
|
|
544
|
+
if (pkVal === undefined || pkVal === null) return;
|
|
545
|
+
const visitKey = `${currentModel.name}:${pkVal}`;
|
|
546
|
+
if (visited.has(visitKey)) return;
|
|
547
|
+
visited.add(visitKey);
|
|
548
|
+
|
|
549
|
+
const activeKnex = getActiveKnex();
|
|
550
|
+
|
|
551
|
+
for (const [relationName, relation] of Object.entries(currentModel.relations || {})) {
|
|
552
|
+
if (!['hasMany', 'hasOne'].includes(relation.type)) continue;
|
|
553
|
+
if (!isCascadeEnabled(relation, 'delete')) continue;
|
|
554
|
+
|
|
555
|
+
let relatedModel;
|
|
556
|
+
try {
|
|
557
|
+
relatedModel = resolveRelationModel(relation);
|
|
558
|
+
} catch {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!relatedModel) continue;
|
|
562
|
+
|
|
563
|
+
const localKey = relation.localKey || currentModel.primaryKey;
|
|
564
|
+
const foreignKey = relation.foreignKey;
|
|
565
|
+
const parentVal = record[localKey];
|
|
566
|
+
if (parentVal === undefined || parentVal === null) continue;
|
|
567
|
+
|
|
568
|
+
let childQuery = activeKnex(relatedModel.table)
|
|
569
|
+
.where(foreignKey, parentVal);
|
|
570
|
+
|
|
571
|
+
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
572
|
+
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const children = await childQuery.select('*');
|
|
576
|
+
if (!children || children.length === 0) continue;
|
|
577
|
+
|
|
578
|
+
// Delete deeper relations first to avoid FK constraint violations
|
|
579
|
+
const hasDeeper = Object.values(relatedModel.relations || {}).some(
|
|
580
|
+
r => ['hasMany', 'hasOne'].includes(r.type) && isCascadeEnabled(r, 'delete')
|
|
581
|
+
);
|
|
582
|
+
if (hasDeeper) {
|
|
583
|
+
for (const child of children) {
|
|
584
|
+
await cascadeHardDelete(relatedModel, child, options, visited);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const childIds = children.map(c => c[relatedModel.primaryKey]);
|
|
589
|
+
await activeKnex(relatedModel.table)
|
|
590
|
+
.whereIn(relatedModel.primaryKey, childIds)
|
|
591
|
+
.delete();
|
|
592
|
+
|
|
593
|
+
if (cacheLayer) {
|
|
594
|
+
cacheLayer.invalidateModelAll(relatedModel);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (ModelEvents.hasListeners(relatedModel.name, Hooks.AFTER_DELETE)) {
|
|
598
|
+
for (const child of children) {
|
|
599
|
+
const childCtx = createEventContext(relatedModel.name, 'delete', getHookTrx());
|
|
600
|
+
ModelEvents.emit(relatedModel.name, Hooks.AFTER_DELETE, child, childCtx);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Cascade restore child records for cascading relations
|
|
608
|
+
* @param {import('./types').ModelDefinition} currentModel
|
|
609
|
+
* @param {Object} trashedRecord
|
|
610
|
+
* @param {Date|string|number} parentDeletedAt
|
|
611
|
+
* @param {Object} [options={}]
|
|
612
|
+
* @param {Set<string>} [visited=new Set()]
|
|
613
|
+
*/
|
|
614
|
+
async function cascadeRestore(currentModel, trashedRecord, parentDeletedAt, options = {}, visited = new Set()) {
|
|
615
|
+
const pkVal = trashedRecord[currentModel.primaryKey];
|
|
616
|
+
if (pkVal === undefined || pkVal === null) return;
|
|
617
|
+
const visitKey = `${currentModel.name}:${pkVal}`;
|
|
618
|
+
if (visited.has(visitKey)) return;
|
|
619
|
+
visited.add(visitKey);
|
|
620
|
+
|
|
621
|
+
const activeKnex = getActiveKnex();
|
|
622
|
+
|
|
623
|
+
for (const [relationName, relation] of Object.entries(currentModel.relations || {})) {
|
|
624
|
+
if (!['hasMany', 'hasOne'].includes(relation.type)) continue;
|
|
625
|
+
if (!isCascadeEnabled(relation, 'restore')) continue;
|
|
626
|
+
|
|
627
|
+
let relatedModel;
|
|
628
|
+
try {
|
|
629
|
+
relatedModel = resolveRelationModel(relation);
|
|
630
|
+
} catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (!relatedModel || !relatedModel.scopes.softDelete) continue;
|
|
634
|
+
|
|
635
|
+
const localKey = relation.localKey || currentModel.primaryKey;
|
|
636
|
+
const foreignKey = relation.foreignKey;
|
|
637
|
+
const parentVal = trashedRecord[localKey];
|
|
638
|
+
if (parentVal === undefined || parentVal === null) continue;
|
|
639
|
+
|
|
640
|
+
let childQuery = activeKnex(relatedModel.table)
|
|
641
|
+
.where(foreignKey, parentVal)
|
|
642
|
+
.whereNotNull('deleted_at');
|
|
643
|
+
|
|
644
|
+
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
645
|
+
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Selective restore by matching deletion timestamp
|
|
649
|
+
if (!options.allChildren && parentDeletedAt != null) {
|
|
650
|
+
childQuery = childQuery.where('deleted_at', parentDeletedAt);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const children = await childQuery.select('*');
|
|
654
|
+
if (!children || children.length === 0) continue;
|
|
655
|
+
|
|
656
|
+
const hasDeeper = Object.values(relatedModel.relations || {}).some(
|
|
657
|
+
r => ['hasMany', 'hasOne'].includes(r.type) && isCascadeEnabled(r, 'restore')
|
|
658
|
+
);
|
|
659
|
+
if (hasDeeper) {
|
|
660
|
+
for (const child of children) {
|
|
661
|
+
await cascadeRestore(relatedModel, child, child.deleted_at || parentDeletedAt, options, visited);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const childIds = children.map(c => c[relatedModel.primaryKey]);
|
|
666
|
+
await activeKnex(relatedModel.table)
|
|
667
|
+
.whereIn(relatedModel.primaryKey, childIds)
|
|
668
|
+
.update(getRestoreData());
|
|
669
|
+
|
|
670
|
+
if (cacheLayer) {
|
|
671
|
+
cacheLayer.invalidateModelAll(relatedModel);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (ModelEvents.hasListeners(relatedModel.name, Hooks.AFTER_RESTORE)) {
|
|
675
|
+
for (const child of children) {
|
|
676
|
+
const childCtx = createEventContext(relatedModel.name, 'restore', getHookTrx());
|
|
677
|
+
ModelEvents.emit(relatedModel.name, Hooks.AFTER_RESTORE, { ...child, deleted_at: null }, childCtx);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Delete a record by primary key (soft delete if enabled, with relation cascading)
|
|
462
685
|
* @param {number|string} id - Primary key value
|
|
686
|
+
* @param {Object} [options={}] - Delete options ({ cascade?: boolean, deletedAt?: Date })
|
|
463
687
|
* @returns {Promise<boolean>}
|
|
464
688
|
*/
|
|
465
|
-
async function del(id) {
|
|
689
|
+
async function del(id, options = {}) {
|
|
466
690
|
const ctx = createEventContext(model.name, 'delete', getHookTrx());
|
|
467
691
|
|
|
468
|
-
// Get the record before deletion for hooks
|
|
692
|
+
// Get the record before deletion for hooks and cascading
|
|
469
693
|
const record = await findById(id);
|
|
470
694
|
if (!record) {
|
|
471
695
|
return false;
|
|
@@ -478,39 +702,76 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
478
702
|
}
|
|
479
703
|
|
|
480
704
|
let success = false;
|
|
705
|
+
const deletedAt = (options && options.deletedAt instanceof Date) ? options.deletedAt : new Date();
|
|
481
706
|
|
|
482
707
|
// Soft delete if enabled
|
|
483
708
|
if (model.scopes.softDelete) {
|
|
484
709
|
const updated = await baseQuery()
|
|
485
710
|
.where(model.primaryKey, id)
|
|
486
|
-
.update(getSoftDeleteData());
|
|
711
|
+
.update(getSoftDeleteData(deletedAt));
|
|
487
712
|
success = updated > 0;
|
|
713
|
+
|
|
714
|
+
if (success) {
|
|
715
|
+
if (cacheLayer) {
|
|
716
|
+
cacheLayer.invalidateModelAll(model);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!options || options.cascade !== false) {
|
|
720
|
+
await cascadeSoftDelete(model, record, deletedAt, options);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Emit afterDelete
|
|
724
|
+
ModelEvents.emit(model.name, Hooks.AFTER_DELETE, record, ctx);
|
|
725
|
+
}
|
|
488
726
|
} else {
|
|
489
727
|
// Hard delete
|
|
728
|
+
// Cascade hard-delete child records first to satisfy FK constraints
|
|
729
|
+
if (!options || options.cascade !== false) {
|
|
730
|
+
await cascadeHardDelete(model, record, options);
|
|
731
|
+
}
|
|
732
|
+
|
|
490
733
|
const deleted = await baseQuery()
|
|
491
734
|
.where(model.primaryKey, id)
|
|
492
735
|
.delete();
|
|
493
736
|
success = deleted > 0;
|
|
494
|
-
}
|
|
495
737
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
738
|
+
if (success) {
|
|
739
|
+
if (cacheLayer) {
|
|
740
|
+
cacheLayer.invalidateModelAll(model);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Emit afterDelete
|
|
744
|
+
ModelEvents.emit(model.name, Hooks.AFTER_DELETE, record, ctx);
|
|
745
|
+
}
|
|
499
746
|
}
|
|
500
747
|
|
|
501
748
|
return success;
|
|
502
749
|
}
|
|
503
750
|
|
|
504
751
|
/**
|
|
505
|
-
* Force delete a record (bypass soft delete)
|
|
752
|
+
* Force delete a record (bypass soft delete, cascading to related models)
|
|
506
753
|
* @param {number|string} id - Primary key value
|
|
754
|
+
* @param {Object} [options={}] - Force delete options ({ cascade?: boolean })
|
|
507
755
|
* @returns {Promise<boolean>}
|
|
508
756
|
*/
|
|
509
|
-
async function forceDelete(id) {
|
|
757
|
+
async function forceDelete(id, options = {}) {
|
|
758
|
+
const record = await getActiveKnex()(model.table)
|
|
759
|
+
.where(model.primaryKey, id)
|
|
760
|
+
.first();
|
|
761
|
+
|
|
762
|
+
if (!record) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!options || options.cascade !== false) {
|
|
767
|
+
await cascadeHardDelete(model, record, options);
|
|
768
|
+
}
|
|
769
|
+
|
|
510
770
|
// Use raw query to bypass soft delete scope
|
|
511
771
|
const deleted = await getActiveKnex()(model.table)
|
|
512
772
|
.where(model.primaryKey, id)
|
|
513
773
|
.delete();
|
|
774
|
+
|
|
514
775
|
if (cacheLayer && deleted > 0) {
|
|
515
776
|
cacheLayer.invalidateModelAll(model);
|
|
516
777
|
}
|
|
@@ -518,11 +779,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
518
779
|
}
|
|
519
780
|
|
|
520
781
|
/**
|
|
521
|
-
* Restore a soft-deleted record
|
|
782
|
+
* Restore a soft-deleted record (with selective cascading restore)
|
|
522
783
|
* @param {number|string} id - Primary key value
|
|
784
|
+
* @param {Object} [options={}] - Restore options ({ cascade?: boolean, allChildren?: boolean })
|
|
523
785
|
* @returns {Promise<Object|null>}
|
|
524
786
|
*/
|
|
525
|
-
async function restore(id) {
|
|
787
|
+
async function restore(id, options = {}) {
|
|
526
788
|
if (!model.scopes.softDelete) {
|
|
527
789
|
throw new Error(`Model "${model.name}" does not have soft delete enabled`);
|
|
528
790
|
}
|
|
@@ -549,12 +811,20 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
549
811
|
const updated = await getActiveKnex()(model.table)
|
|
550
812
|
.where(model.primaryKey, id)
|
|
551
813
|
.whereNotNull('deleted_at')
|
|
552
|
-
.update(
|
|
814
|
+
.update(getRestoreData());
|
|
553
815
|
|
|
554
816
|
if (updated === 0) {
|
|
555
817
|
return null;
|
|
556
818
|
}
|
|
557
819
|
|
|
820
|
+
if (cacheLayer) {
|
|
821
|
+
cacheLayer.invalidateModelAll(model);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (!options || options.cascade !== false) {
|
|
825
|
+
await cascadeRestore(model, trashedRecord, trashedRecord.deleted_at, options);
|
|
826
|
+
}
|
|
827
|
+
|
|
558
828
|
const record = await findById(id, {}, false);
|
|
559
829
|
|
|
560
830
|
// Emit afterRestore
|
|
@@ -619,10 +889,49 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
619
889
|
return query().with(...relations);
|
|
620
890
|
}
|
|
621
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Find records matching conditions (with optional relations to eager load)
|
|
894
|
+
* @param {Object} [conditions={}] - WHERE conditions
|
|
895
|
+
* @param {import('./types').FindOptions} [options={}] - Find options
|
|
896
|
+
* @returns {Promise<Object[]>}
|
|
897
|
+
*/
|
|
898
|
+
async function find(conditions = {}, options = {}) {
|
|
899
|
+
if (!conditions || Object.keys(conditions).length === 0) {
|
|
900
|
+
return findAll(options);
|
|
901
|
+
}
|
|
902
|
+
const withs = options.include || options.with || [];
|
|
903
|
+
let qb = query().where(conditions);
|
|
904
|
+
if (withs.length > 0) {
|
|
905
|
+
qb = qb.with(withs);
|
|
906
|
+
}
|
|
907
|
+
if (options.select && options.select.length > 0) {
|
|
908
|
+
qb = qb.select(options.select);
|
|
909
|
+
}
|
|
910
|
+
if (options.limit !== undefined) {
|
|
911
|
+
qb = qb.limit(options.limit);
|
|
912
|
+
}
|
|
913
|
+
if (options.offset !== undefined) {
|
|
914
|
+
qb = qb.offset(options.offset);
|
|
915
|
+
}
|
|
916
|
+
return qb.list();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Search records using Full-Text / Keyword Search
|
|
921
|
+
* @param {string} term - Search query
|
|
922
|
+
* @param {string|string[]} [columns] - Columns to search
|
|
923
|
+
* @param {Object} [options] - Search options
|
|
924
|
+
* @returns {import('./query-builder').QueryBuilder}
|
|
925
|
+
*/
|
|
926
|
+
function search(term, columns, options) {
|
|
927
|
+
return query().search(term, columns, options);
|
|
928
|
+
}
|
|
929
|
+
|
|
622
930
|
return {
|
|
623
931
|
findById,
|
|
624
932
|
findOne,
|
|
625
933
|
findAll,
|
|
934
|
+
find,
|
|
626
935
|
create,
|
|
627
936
|
createMany,
|
|
628
937
|
update,
|
|
@@ -631,10 +940,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
631
940
|
forceDelete,
|
|
632
941
|
restore,
|
|
633
942
|
query,
|
|
943
|
+
search,
|
|
634
944
|
raw,
|
|
635
945
|
count,
|
|
636
946
|
exists,
|
|
637
947
|
with: withRelations,
|
|
948
|
+
include: withRelations,
|
|
638
949
|
// Expose model for introspection
|
|
639
950
|
model,
|
|
640
951
|
};
|
package/core/orm/scopes.js
CHANGED
|
@@ -142,10 +142,11 @@ function applyInsertNanoidPrimary(data, model) {
|
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
144
|
* Get soft delete data (for UPDATE instead of DELETE)
|
|
145
|
+
* @param {Date|string|number} [date=new Date()] - Soft delete timestamp
|
|
145
146
|
* @returns {Object} Soft delete update data
|
|
146
147
|
*/
|
|
147
|
-
function getSoftDeleteData() {
|
|
148
|
-
return { deleted_at: new Date() };
|
|
148
|
+
function getSoftDeleteData(date = new Date()) {
|
|
149
|
+
return { deleted_at: date instanceof Date ? date : new Date(date) };
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
@@ -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
|
+
};
|