webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
@@ -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');
@@ -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
@@ -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
  /**
package/core/orm/types.js CHANGED
@@ -79,12 +79,17 @@
79
79
  * @typedef {'belongsTo'|'hasMany'|'hasOne'} RelationType
80
80
  */
81
81
 
82
+ /**
83
+ * @typedef {boolean|'delete'|'restore'|'all'|string[]|{delete?: boolean, restore?: boolean}} RelationCascadeOption
84
+ */
85
+
82
86
  /**
83
87
  * @typedef {Object} RelationDefinition
84
88
  * @property {RelationType} type - Type of relation
85
- * @property {() => ModelDefinition} model - Lazy reference to related model
89
+ * @property {string|(() => ModelDefinition|string)} model - Related model reference (function or model name)
86
90
  * @property {string} foreignKey - Foreign key column name
87
91
  * @property {string} [localKey='id'] - Local key column name (for hasMany/hasOne)
92
+ * @property {RelationCascadeOption} [cascade=false] - Cascading delete and restore configuration
88
93
  */
89
94
 
90
95
  /**