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.
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +246 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +90 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +33 -0
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +1 -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 +63 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +30 -5
- 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 +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/orm/index.js +2 -1
- package/core/orm/model.js +51 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +274 -14
- package/index.js +4 -0
- package/package.json +7 -5
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- 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 +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -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/bundle/index.js +2 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +7 -1
- package/src/njk-frontmatter.js +3 -0
- 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
|
-
|
|
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
|
};
|
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');
|
|
@@ -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
|
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
|
/**
|
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 -
|
|
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
|
/**
|