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.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -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
- * Delete a record by primary key (soft delete if enabled)
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
- if (success) {
497
- // Emit afterDelete
498
- ModelEvents.emit(model.name, Hooks.AFTER_DELETE, record, ctx);
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({ deleted_at: null });
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
  };
@@ -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
+ };