webspresso 0.0.99 → 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 (60) hide show
  1. package/.agents/AGENTS.md +2 -2
  2. package/.agents/AUTH.md +246 -0
  3. package/.agents/CLI.md +47 -0
  4. package/.agents/ERRORS.md +59 -0
  5. package/.agents/ORM.md +90 -0
  6. package/.agents/PLUGINS.md +44 -7
  7. package/.agents/QUEUE.md +18 -10
  8. package/.agents/ROUTING.md +33 -0
  9. package/.agents/STREAMING.md +35 -0
  10. package/AGENTS.md +1 -0
  11. package/README.md +145 -93
  12. package/bin/commands/add-docker.js +467 -0
  13. package/bin/commands/deploy-check.js +339 -0
  14. package/bin/webspresso.js +4 -0
  15. package/core/auth/index.js +63 -1
  16. package/core/auth/magic-link.js +170 -0
  17. package/core/auth/manager.js +343 -3
  18. package/core/auth/middleware.js +30 -5
  19. package/core/auth/session-manager.js +255 -0
  20. package/core/auth/sessions.js +351 -0
  21. package/core/auth/tokens.js +1 -0
  22. package/core/auth/totp.js +4 -1
  23. package/core/auth/user-agent.js +161 -0
  24. package/core/orm/index.js +2 -1
  25. package/core/orm/model.js +51 -1
  26. package/core/orm/repository.js +283 -13
  27. package/core/orm/scopes.js +3 -2
  28. package/core/orm/types.js +6 -1
  29. package/core/queue/cron.js +252 -39
  30. package/core/queue/manager.js +20 -2
  31. package/core/queue/scheduler.js +115 -59
  32. package/core/ssr/stream.js +62 -2
  33. package/index.d.ts +274 -14
  34. package/index.js +4 -0
  35. package/package.json +7 -5
  36. package/plugins/admin-panel/modules/admin-users.js +1 -1
  37. package/plugins/audit-log/admin-component.js +54 -54
  38. package/plugins/audit-log/index.js +2 -2
  39. package/plugins/content/client/inline-edit.js +2 -46
  40. package/plugins/cors.js +13 -2
  41. package/plugins/email/auth-bridge.js +66 -0
  42. package/plugins/email/bundled-templates.js +15 -0
  43. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  44. package/plugins/file-manager/admin-component.js +49 -49
  45. package/plugins/file-manager/index.js +4 -4
  46. package/plugins/index.js +2 -0
  47. package/plugins/webhooks/admin-component.js +608 -0
  48. package/plugins/webhooks/api-handlers.js +237 -0
  49. package/plugins/webhooks/crypto.js +144 -0
  50. package/plugins/webhooks/dispatcher.js +708 -0
  51. package/plugins/webhooks/index.js +263 -0
  52. package/plugins/webhooks/matcher.js +113 -0
  53. package/plugins/webhooks/migration-template.js +75 -0
  54. package/plugins/webhooks/models.js +98 -0
  55. package/plugins/webhooks/sender.js +176 -0
  56. package/src/bundle/index.js +2 -1
  57. package/src/file-router.js +173 -4
  58. package/src/helpers.js +7 -1
  59. package/src/njk-frontmatter.js +3 -0
  60. package/src/routing/cascading.js +112 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Webspresso Zero-Dependency User-Agent & Device Parser
3
+ * Extracts browser, operating system, and device category from User-Agent header
4
+ * @module core/auth/user-agent
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * @typedef {Object} DeviceInfo
11
+ * @property {string} browser - Browser name with major version (e.g. 'Chrome 128', 'Safari 17')
12
+ * @property {string} os - Operating system name (e.g. 'macOS 14', 'Windows 11', 'iOS 17')
13
+ * @property {'desktop'|'mobile'|'tablet'|'bot'|'unknown'} device - Device category
14
+ * @property {boolean} isMobile
15
+ * @property {boolean} isTablet
16
+ * @property {boolean} isDesktop
17
+ * @property {boolean} isBot
18
+ */
19
+
20
+ /**
21
+ * Extract client IP address handling proxies
22
+ * @param {Object} req - Express / Node.js HTTP request
23
+ * @returns {string}
24
+ */
25
+ function getClientIp(req) {
26
+ if (!req) return '127.0.0.1';
27
+
28
+ const forwarded = req.headers?.['x-forwarded-for'];
29
+ if (forwarded) {
30
+ const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
31
+ const first = Array.isArray(ips) ? ips[0] : ips;
32
+ if (first) return first.trim();
33
+ }
34
+
35
+ return req.headers?.['x-real-ip'] ||
36
+ req.ip ||
37
+ req.connection?.remoteAddress ||
38
+ req.socket?.remoteAddress ||
39
+ '127.0.0.1';
40
+ }
41
+
42
+ /**
43
+ * Parse raw User-Agent header string into structured device info
44
+ * Zero external dependencies — pure regex extraction
45
+ *
46
+ * @param {string} [userAgent='']
47
+ * @returns {DeviceInfo}
48
+ */
49
+ function parseUserAgent(userAgent = '') {
50
+ const ua = String(userAgent || '').trim();
51
+
52
+ if (!ua) {
53
+ return {
54
+ browser: 'Unknown',
55
+ os: 'Unknown',
56
+ device: 'unknown',
57
+ isMobile: false,
58
+ isTablet: false,
59
+ isDesktop: false,
60
+ isBot: false,
61
+ };
62
+ }
63
+
64
+ // 1. Detect Bots / Crawlers
65
+ const botPattern = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
66
+ const isBot = botPattern.test(ua);
67
+
68
+ // 2. Detect Operating System
69
+ let os = 'Unknown';
70
+ if (/iPad/i.test(ua)) {
71
+ const match = ua.match(/OS ([\d_\.]+)/i);
72
+ os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
73
+ } else if (/iPhone|iPod/i.test(ua)) {
74
+ const match = ua.match(/OS ([\d_\.]+)/i);
75
+ os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
76
+ } else if (/Android/i.test(ua)) {
77
+ const match = ua.match(/Android (\d+(\.\d+)?)/i);
78
+ os = match ? `Android ${match[1]}` : 'Android';
79
+ } else if (/Macintosh|Mac OS X/i.test(ua)) {
80
+ const match = ua.match(/Mac OS X ([\d_\.]+)/i);
81
+ if (match) {
82
+ const ver = match[1].replace(/_/g, '.');
83
+ os = `macOS ${ver}`;
84
+ } else {
85
+ os = 'macOS';
86
+ }
87
+ } else if (/Windows/i.test(ua)) {
88
+ if (/Windows NT 10\.0/i.test(ua)) os = 'Windows 10/11';
89
+ else if (/Windows NT 6\.3/i.test(ua)) os = 'Windows 8.1';
90
+ else if (/Windows NT 6\.2/i.test(ua)) os = 'Windows 8';
91
+ else if (/Windows NT 6\.1/i.test(ua)) os = 'Windows 7';
92
+ else os = 'Windows';
93
+ } else if (/CrOS/i.test(ua)) {
94
+ os = 'ChromeOS';
95
+ } else if (/Linux/i.test(ua)) {
96
+ if (/Ubuntu/i.test(ua)) os = 'Ubuntu Linux';
97
+ else if (/Fedora/i.test(ua)) os = 'Fedora Linux';
98
+ else os = 'Linux';
99
+ }
100
+
101
+ // 3. Detect Browser
102
+ let browser = 'Unknown';
103
+ if (/Edg(e|A|iOS)?\/(\d+)/i.test(ua)) {
104
+ const match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i);
105
+ browser = `Edge ${match ? match[2] : ''}`.trim();
106
+ } else if (/OPR\/(\d+)/i.test(ua)) {
107
+ const match = ua.match(/OPR\/(\d+)/i);
108
+ browser = `Opera ${match ? match[1] : ''}`.trim();
109
+ } else if (/SamsungBrowser\/(\d+)/i.test(ua)) {
110
+ const match = ua.match(/SamsungBrowser\/(\d+)/i);
111
+ browser = `Samsung Internet ${match ? match[1] : ''}`.trim();
112
+ } else if (/Brave\/(\d+)/i.test(ua)) {
113
+ const match = ua.match(/Brave\/(\d+)/i);
114
+ browser = `Brave ${match ? match[1] : ''}`.trim();
115
+ } else if (/Vivaldi\/(\d+)/i.test(ua)) {
116
+ const match = ua.match(/Vivaldi\/(\d+)/i);
117
+ browser = `Vivaldi ${match ? match[1] : ''}`.trim();
118
+ } else if (/Chrome\/(\d+)/i.test(ua) && !/Chromium/i.test(ua)) {
119
+ const match = ua.match(/Chrome\/(\d+)/i);
120
+ browser = `Chrome ${match ? match[1] : ''}`.trim();
121
+ } else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
122
+ const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
123
+ browser = `Firefox ${match ? match[1] : ''}`.trim();
124
+ } else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
125
+ const match = ua.match(/Version\/(\d+)/i);
126
+ browser = `Safari ${match ? match[1] : ''}`.trim();
127
+ } else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
128
+ browser = 'Safari';
129
+ } else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
130
+ browser = 'Internet Explorer';
131
+ } else if (isBot) {
132
+ const botNameMatch = ua.match(/(googlebot|bingbot|duckduckbot|slurp|twitterbot|slackbot|curl|wget)/i);
133
+ browser = botNameMatch ? botNameMatch[1] : 'Bot';
134
+ }
135
+
136
+ // 4. Detect Device Category
137
+ const isTablet = /iPad|tablet/i.test(ua) || (/android/i.test(ua) && !/mobile/i.test(ua));
138
+ const isMobile = !isTablet && /mobile|iphone|ipod|android|blackberry|opera mini|windows phone/i.test(ua);
139
+ const isDesktop = !isTablet && !isMobile && !isBot;
140
+
141
+ let device = 'unknown';
142
+ if (isBot) device = 'bot';
143
+ else if (isTablet) device = 'tablet';
144
+ else if (isMobile) device = 'mobile';
145
+ else if (isDesktop) device = 'desktop';
146
+
147
+ return {
148
+ browser,
149
+ os,
150
+ device,
151
+ isMobile,
152
+ isTablet,
153
+ isDesktop,
154
+ isBot,
155
+ };
156
+ }
157
+
158
+ module.exports = {
159
+ getClientIp,
160
+ parseUserAgent,
161
+ };
package/core/orm/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  const path = require('path');
8
8
  const fs = require('fs');
9
9
  const { createSchemaHelpers, extractColumnsFromSchema, getColumnMeta } = require('./schema-helpers');
10
- const { defineModel, getModel, getAllModels, hasModel, clearRegistry, resolveModelName } = require('./model');
10
+ const { defineModel, getModel, getAllModels, hasModel, clearRegistry, resolveModelName, isCascadeEnabled } = require('./model');
11
11
  const { createRepository } = require('./repository');
12
12
  const { createMigrationManager } = require('./migrations');
13
13
  const { createSeeder } = require('./seeder');
@@ -358,6 +358,7 @@ module.exports = {
358
358
  getAllModels,
359
359
  hasModel,
360
360
  clearRegistry,
361
+ isCascadeEnabled,
361
362
  // Column utilities
362
363
  extractColumnsFromSchema,
363
364
  getColumnMeta,
package/core/orm/model.js CHANGED
@@ -83,7 +83,21 @@ function defineModel(options) {
83
83
  );
84
84
  }
85
85
 
86
- normalizedRelations[relationName] = relation;
86
+ if (relation.cascade !== undefined && relation.cascade !== null) {
87
+ const validCascade = typeof relation.cascade === 'boolean' ||
88
+ typeof relation.cascade === 'string' ||
89
+ Array.isArray(relation.cascade) ||
90
+ (typeof relation.cascade === 'object' && relation.cascade !== null);
91
+ if (!validCascade) {
92
+ throw new Error(
93
+ `Invalid cascade option for relation "${relationName}" in model "${name}". Must be boolean, string, array, or object.`
94
+ );
95
+ }
96
+ }
97
+
98
+ normalizedRelations[relationName] = {
99
+ ...relation,
100
+ };
87
101
  }
88
102
 
89
103
  // Create model definition
@@ -277,6 +291,41 @@ function getRelationKeys(model, relationName) {
277
291
  };
278
292
  }
279
293
 
294
+ /**
295
+ * Check if a relation has cascade enabled for a given operation ('delete' or 'restore')
296
+ * @param {import('./types').RelationDefinition} relation - Relation definition
297
+ * @param {'delete'|'restore'} operation - Operation to check
298
+ * @returns {boolean}
299
+ */
300
+ function isCascadeEnabled(relation, operation) {
301
+ if (!relation || relation.cascade === undefined || relation.cascade === null) {
302
+ return false;
303
+ }
304
+ const { cascade } = relation;
305
+ if (cascade === true) {
306
+ return true;
307
+ }
308
+ if (cascade === false) {
309
+ return false;
310
+ }
311
+ const op = String(operation).toLowerCase();
312
+ if (typeof cascade === 'string') {
313
+ const c = cascade.toLowerCase();
314
+ return c === op || c === 'all';
315
+ }
316
+ if (Array.isArray(cascade)) {
317
+ return cascade.some(item => {
318
+ if (typeof item !== 'string') return false;
319
+ const c = item.toLowerCase();
320
+ return c === op || c === 'all';
321
+ });
322
+ }
323
+ if (typeof cascade === 'object' && cascade !== null) {
324
+ return Boolean(cascade[op]);
325
+ }
326
+ return false;
327
+ }
328
+
280
329
  module.exports = {
281
330
  defineModel,
282
331
  getModel,
@@ -287,6 +336,7 @@ module.exports = {
287
336
  resolveModelName,
288
337
  resolveRelationModel,
289
338
  getRelationKeys,
339
+ isCascadeEnabled,
290
340
  // Export registry for testing
291
341
  _registry: modelRegistry,
292
342
  };
@@ -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
  /**