webspresso 0.0.102 → 0.0.103
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/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/core/auth/jwt.js +10 -17
- package/core/auth/user-agent.js +61 -42
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/events/dispatcher.js +56 -33
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +22 -5
- package/package.json +2 -1
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/site-analytics/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +245 -46
- package/src/api/api-loader.js +1 -1
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +139 -26
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +23 -7
- package/src/services/validator.js +57 -32
package/core/orm/model.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* @module core/orm/model
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const z = require('zod');
|
|
7
8
|
const { trimUrlPathSlashes } = require('../url-path-normalize');
|
|
8
|
-
const { extractColumnsFromSchema } = require('./schema-helpers');
|
|
9
|
+
const { createSchemaHelpers, extractColumnsFromSchema } = require('./schema-helpers');
|
|
9
10
|
const { ModelEvents, Hooks } = require('./events');
|
|
11
|
+
const zdb = z ? createSchemaHelpers(z) : null;
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Global model registry
|
|
@@ -34,6 +36,10 @@ function defineModel(options) {
|
|
|
34
36
|
queryLimits = {},
|
|
35
37
|
cache: cacheOption,
|
|
36
38
|
searchable,
|
|
39
|
+
timestamps: timestampsOpt,
|
|
40
|
+
softDelete: softDeleteOpt,
|
|
41
|
+
softDeletes: softDeletesOpt,
|
|
42
|
+
options: nestedOptions = {},
|
|
37
43
|
} = options;
|
|
38
44
|
|
|
39
45
|
// Validate required fields
|
|
@@ -43,15 +49,94 @@ function defineModel(options) {
|
|
|
43
49
|
if (!table || typeof table !== 'string') {
|
|
44
50
|
throw new Error('Model table is required and must be a string');
|
|
45
51
|
}
|
|
46
|
-
if (!schema || typeof schema.parse !== 'function') {
|
|
52
|
+
if (!schema || typeof schema !== 'object' || (typeof schema.parse !== 'function' && Object.keys(schema).length === 0)) {
|
|
47
53
|
throw new Error('Model schema is required and must be a Zod schema');
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
// Resolve timestamps and softDelete options (from top-level flags, nested options, or scopes)
|
|
57
|
+
const timestampsConfig = timestampsOpt !== undefined
|
|
58
|
+
? timestampsOpt
|
|
59
|
+
: (nestedOptions.timestamps !== undefined ? nestedOptions.timestamps : scopes.timestamps);
|
|
60
|
+
|
|
61
|
+
const softDeleteConfig = softDeleteOpt !== undefined
|
|
62
|
+
? softDeleteOpt
|
|
63
|
+
: (softDeletesOpt !== undefined
|
|
64
|
+
? softDeletesOpt
|
|
65
|
+
: (nestedOptions.softDelete !== undefined
|
|
66
|
+
? nestedOptions.softDelete
|
|
67
|
+
: (nestedOptions.softDeletes !== undefined
|
|
68
|
+
? nestedOptions.softDeletes
|
|
69
|
+
: scopes.softDelete)));
|
|
70
|
+
|
|
71
|
+
const isPlainObject = typeof schema.parse !== 'function';
|
|
72
|
+
const injectedFields = {};
|
|
73
|
+
|
|
74
|
+
if (timestampsConfig) {
|
|
75
|
+
const isSnake = typeof timestampsConfig === 'object'
|
|
76
|
+
? Boolean(timestampsConfig.snakeCase || timestampsConfig.format === 'snake')
|
|
77
|
+
: (timestampsConfig === 'snake');
|
|
78
|
+
const createdCol = (typeof timestampsConfig === 'object' && timestampsConfig.createdAt)
|
|
79
|
+
|| (isSnake ? 'created_at' : 'createdAt');
|
|
80
|
+
const updatedCol = (typeof timestampsConfig === 'object' && timestampsConfig.updatedAt)
|
|
81
|
+
|| (isSnake ? 'updated_at' : 'updatedAt');
|
|
82
|
+
|
|
83
|
+
const hasCreated = isPlainObject
|
|
84
|
+
? (schema[createdCol] !== undefined || schema.created_at !== undefined || schema.createdAt !== undefined)
|
|
85
|
+
: (schema.shape && (schema.shape[createdCol] !== undefined || schema.shape.created_at !== undefined || schema.shape.createdAt !== undefined));
|
|
86
|
+
const hasUpdated = isPlainObject
|
|
87
|
+
? (schema[updatedCol] !== undefined || schema.updated_at !== undefined || schema.updatedAt !== undefined)
|
|
88
|
+
: (schema.shape && (schema.shape[updatedCol] !== undefined || schema.shape.updated_at !== undefined || schema.shape.updatedAt !== undefined));
|
|
89
|
+
|
|
90
|
+
if (!hasCreated && zdb) {
|
|
91
|
+
injectedFields[createdCol] = zdb.timestamp({ auto: 'create' });
|
|
92
|
+
}
|
|
93
|
+
if (!hasUpdated && zdb) {
|
|
94
|
+
injectedFields[updatedCol] = zdb.timestamp({ auto: 'update' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (softDeleteConfig) {
|
|
99
|
+
const isSnake = typeof softDeleteConfig === 'object'
|
|
100
|
+
? Boolean(softDeleteConfig.snakeCase || softDeleteConfig.format === 'snake')
|
|
101
|
+
: (softDeleteConfig === 'snake');
|
|
102
|
+
const deletedCol = (typeof softDeleteConfig === 'object' && softDeleteConfig.deletedAt)
|
|
103
|
+
|| (isSnake ? 'deleted_at' : 'deletedAt');
|
|
104
|
+
|
|
105
|
+
const hasDeleted = isPlainObject
|
|
106
|
+
? (schema[deletedCol] !== undefined || schema.deleted_at !== undefined || schema.deletedAt !== undefined)
|
|
107
|
+
: (schema.shape && (schema.shape[deletedCol] !== undefined || schema.shape.deleted_at !== undefined || schema.shape.deletedAt !== undefined));
|
|
108
|
+
|
|
109
|
+
if (!hasDeleted && zdb) {
|
|
110
|
+
injectedFields[deletedCol] = zdb.softDelete();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let normalizedSchema;
|
|
115
|
+
if (isPlainObject) {
|
|
116
|
+
if (!zdb) {
|
|
117
|
+
throw new Error('Zod is required to compile schema object');
|
|
118
|
+
}
|
|
119
|
+
normalizedSchema = zdb.schema({
|
|
120
|
+
...schema,
|
|
121
|
+
...injectedFields,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
normalizedSchema = schema;
|
|
125
|
+
if (Object.keys(injectedFields).length > 0 && typeof normalizedSchema.extend === 'function') {
|
|
126
|
+
const finalizedInjected = {};
|
|
127
|
+
for (const [key, field] of Object.entries(injectedFields)) {
|
|
128
|
+
finalizedInjected[key] = (field && typeof field._finalize === 'function')
|
|
129
|
+
? field._finalize()
|
|
130
|
+
: field;
|
|
131
|
+
}
|
|
132
|
+
normalizedSchema = normalizedSchema.extend(finalizedInjected);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (normalizedSchema && normalizedSchema.shape) {
|
|
137
|
+
for (const [key, val] of Object.entries(normalizedSchema.shape)) {
|
|
53
138
|
if (val && typeof val._finalize === 'function') {
|
|
54
|
-
|
|
139
|
+
normalizedSchema.shape[key] = val._finalize();
|
|
55
140
|
}
|
|
56
141
|
}
|
|
57
142
|
}
|
|
@@ -105,13 +190,19 @@ function defineModel(options) {
|
|
|
105
190
|
name,
|
|
106
191
|
table,
|
|
107
192
|
schema: normalizedSchema,
|
|
193
|
+
_partialSchema: typeof normalizedSchema?.partial === 'function' ? normalizedSchema.partial() : normalizedSchema,
|
|
108
194
|
primaryKey,
|
|
109
195
|
relations: normalizedRelations,
|
|
110
196
|
scopes: {
|
|
111
|
-
softDelete:
|
|
112
|
-
timestamps:
|
|
197
|
+
softDelete: Boolean(softDeleteConfig),
|
|
198
|
+
timestamps: Boolean(timestampsConfig),
|
|
113
199
|
tenant: scopes.tenant || null,
|
|
114
200
|
},
|
|
201
|
+
options: {
|
|
202
|
+
timestamps: timestampsConfig || false,
|
|
203
|
+
softDelete: softDeleteConfig || false,
|
|
204
|
+
...nestedOptions,
|
|
205
|
+
},
|
|
115
206
|
columns,
|
|
116
207
|
admin: {
|
|
117
208
|
enabled: admin.enabled === true, // Explicit boolean check
|
|
@@ -42,7 +42,7 @@ class QueryBuilder {
|
|
|
42
42
|
constructor(model, knex, initialContext, cacheLayer = null) {
|
|
43
43
|
this.model = model;
|
|
44
44
|
this.knex = knex;
|
|
45
|
-
this.scopeContext = initialContext
|
|
45
|
+
this.scopeContext = initialContext ? { ...initialContext } : createScopeContext();
|
|
46
46
|
this.cacheLayer = cacheLayer;
|
|
47
47
|
this.jsonColumns = getJsonColumns(model);
|
|
48
48
|
|
|
@@ -78,11 +78,11 @@ class QueryBuilder {
|
|
|
78
78
|
where(columnOrConditions, operatorOrValue, value) {
|
|
79
79
|
// Object form: where({ column: value, ... })
|
|
80
80
|
if (typeof columnOrConditions === 'object' && columnOrConditions !== null) {
|
|
81
|
-
for (const
|
|
81
|
+
for (const col of Object.keys(columnOrConditions)) {
|
|
82
82
|
this.state.wheres.push({
|
|
83
83
|
column: col,
|
|
84
84
|
operator: '=',
|
|
85
|
-
value:
|
|
85
|
+
value: columnOrConditions[col],
|
|
86
86
|
boolean: 'and',
|
|
87
87
|
});
|
|
88
88
|
}
|
|
@@ -176,6 +176,22 @@ class QueryBuilder {
|
|
|
176
176
|
return this;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Add a WHERE NOT clause (col != val or multiple columns)
|
|
181
|
+
* @param {string|Object} column - Column name or object of { col: val }
|
|
182
|
+
* @param {*} [value] - Value to compare against
|
|
183
|
+
* @returns {this}
|
|
184
|
+
*/
|
|
185
|
+
whereNot(column, value) {
|
|
186
|
+
if (typeof column === 'object' && column !== null) {
|
|
187
|
+
for (const [col, val] of Object.entries(column)) {
|
|
188
|
+
this.where(col, '!=', val);
|
|
189
|
+
}
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
return this.where(column, '!=', value);
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
/**
|
|
180
196
|
* Add a WHERE NOT IN clause
|
|
181
197
|
* @param {string} column - Column name
|
|
@@ -477,27 +493,37 @@ class QueryBuilder {
|
|
|
477
493
|
* @returns {Promise<Object|null>}
|
|
478
494
|
*/
|
|
479
495
|
async first() {
|
|
480
|
-
const
|
|
496
|
+
const shouldEmit = (
|
|
497
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
498
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
499
|
+
);
|
|
500
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
481
501
|
const self = this;
|
|
482
502
|
|
|
483
503
|
async function loadFromDb() {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
504
|
+
if (shouldEmit) {
|
|
505
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
506
|
+
if (ctx.isCancelled) {
|
|
507
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
508
|
+
}
|
|
487
509
|
}
|
|
488
510
|
|
|
489
511
|
const qb = self.toKnex().first();
|
|
490
512
|
const result = await qb;
|
|
491
513
|
if (!result) return null;
|
|
492
514
|
|
|
493
|
-
|
|
515
|
+
if (self.jsonColumns.size > 0) {
|
|
516
|
+
deserializeJsonFields(result, self.jsonColumns);
|
|
517
|
+
}
|
|
494
518
|
|
|
495
519
|
const withs = self.getWiths();
|
|
496
520
|
if (withs.length > 0) {
|
|
497
521
|
await loadRelations([result], ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
498
522
|
}
|
|
499
523
|
|
|
500
|
-
|
|
524
|
+
if (shouldEmit) {
|
|
525
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
|
|
526
|
+
}
|
|
501
527
|
return result;
|
|
502
528
|
}
|
|
503
529
|
|
|
@@ -543,19 +569,27 @@ class QueryBuilder {
|
|
|
543
569
|
* @returns {Promise<Object[]>}
|
|
544
570
|
*/
|
|
545
571
|
async list() {
|
|
546
|
-
const
|
|
572
|
+
const shouldEmit = (
|
|
573
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
574
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
575
|
+
);
|
|
576
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
547
577
|
const self = this;
|
|
548
578
|
|
|
549
579
|
async function loadFromDb() {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
580
|
+
if (shouldEmit) {
|
|
581
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
582
|
+
if (ctx.isCancelled) {
|
|
583
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
584
|
+
}
|
|
553
585
|
}
|
|
554
586
|
|
|
555
587
|
const results = await self.toKnex();
|
|
556
588
|
|
|
557
|
-
|
|
558
|
-
|
|
589
|
+
if (self.jsonColumns.size > 0) {
|
|
590
|
+
for (const record of results) {
|
|
591
|
+
deserializeJsonFields(record, self.jsonColumns);
|
|
592
|
+
}
|
|
559
593
|
}
|
|
560
594
|
|
|
561
595
|
const withs = self.getWiths();
|
|
@@ -563,8 +597,10 @@ class QueryBuilder {
|
|
|
563
597
|
await loadRelations(results, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
564
598
|
}
|
|
565
599
|
|
|
566
|
-
|
|
567
|
-
|
|
600
|
+
if (shouldEmit) {
|
|
601
|
+
for (const record of results) {
|
|
602
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
603
|
+
}
|
|
568
604
|
}
|
|
569
605
|
return results;
|
|
570
606
|
}
|
|
@@ -676,13 +712,19 @@ class QueryBuilder {
|
|
|
676
712
|
perPage = validated.perPage;
|
|
677
713
|
page = Math.max(1, parseInt(page, 10) || 1);
|
|
678
714
|
|
|
679
|
-
const
|
|
715
|
+
const shouldEmit = (
|
|
716
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
717
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
718
|
+
);
|
|
719
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
680
720
|
const self = this;
|
|
681
721
|
|
|
682
722
|
async function loadFromDb() {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
723
|
+
if (shouldEmit) {
|
|
724
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
725
|
+
if (ctx.isCancelled) {
|
|
726
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
727
|
+
}
|
|
686
728
|
}
|
|
687
729
|
|
|
688
730
|
const base = self.toKnex({ includeLimitOffset: false });
|
|
@@ -693,8 +735,10 @@ class QueryBuilder {
|
|
|
693
735
|
const offset = (page - 1) * perPage;
|
|
694
736
|
const data = await base.clone().limit(perPage).offset(offset);
|
|
695
737
|
|
|
696
|
-
|
|
697
|
-
|
|
738
|
+
if (self.jsonColumns.size > 0) {
|
|
739
|
+
for (const record of data) {
|
|
740
|
+
deserializeJsonFields(record, self.jsonColumns);
|
|
741
|
+
}
|
|
698
742
|
}
|
|
699
743
|
|
|
700
744
|
const withs = self.getWiths();
|
|
@@ -702,8 +746,10 @@ class QueryBuilder {
|
|
|
702
746
|
await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
703
747
|
}
|
|
704
748
|
|
|
705
|
-
|
|
706
|
-
|
|
749
|
+
if (shouldEmit) {
|
|
750
|
+
for (const record of data) {
|
|
751
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
752
|
+
}
|
|
707
753
|
}
|
|
708
754
|
|
|
709
755
|
return {
|
package/core/orm/repository.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
applyUpdateModifiers,
|
|
13
13
|
getSoftDeleteData,
|
|
14
14
|
getRestoreData,
|
|
15
|
+
getSoftDeleteColumn,
|
|
15
16
|
createScopeContext,
|
|
16
17
|
applyScopes,
|
|
17
18
|
} = require('./scopes');
|
|
@@ -20,6 +21,24 @@ const { ModelEvents, createEventContext, Hooks, HookCancellationError } = requir
|
|
|
20
21
|
const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('./json-fields');
|
|
21
22
|
const { getAmbientTransaction } = require('./transaction');
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Get or cache partial schema for model
|
|
26
|
+
* @param {import('./types').ModelDefinition} model
|
|
27
|
+
* @returns {import('zod').ZodTypeAny}
|
|
28
|
+
*/
|
|
29
|
+
function getPartialSchema(model) {
|
|
30
|
+
if (model && model._partialSchema) {
|
|
31
|
+
return model._partialSchema;
|
|
32
|
+
}
|
|
33
|
+
const partial = (model && model.schema && typeof model.schema.partial === 'function')
|
|
34
|
+
? model.schema.partial()
|
|
35
|
+
: model?.schema;
|
|
36
|
+
if (model) {
|
|
37
|
+
model._partialSchema = partial;
|
|
38
|
+
}
|
|
39
|
+
return partial;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
/**
|
|
24
43
|
* Create a repository for a model
|
|
25
44
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
@@ -29,7 +48,7 @@ const { getAmbientTransaction } = require('./transaction');
|
|
|
29
48
|
* @returns {import('./types').Repository}
|
|
30
49
|
*/
|
|
31
50
|
function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
32
|
-
const scopeContext = initialContext
|
|
51
|
+
const scopeContext = initialContext ? { ...initialContext } : createScopeContext();
|
|
33
52
|
const jsonColumns = getJsonColumns(model);
|
|
34
53
|
const hasJson = jsonColumns.size > 0;
|
|
35
54
|
|
|
@@ -302,7 +321,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
// Validate with Zod schema (partial for insert - allows auto fields to be missing)
|
|
305
|
-
const validated = model
|
|
324
|
+
const validated = getPartialSchema(model).parse(workingData);
|
|
306
325
|
|
|
307
326
|
// Emit afterValidation
|
|
308
327
|
ModelEvents.emit(model.name, Hooks.AFTER_VALIDATION, validated, ctx);
|
|
@@ -381,7 +400,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
// Validate with Zod schema (partial for update)
|
|
384
|
-
const validated = model
|
|
403
|
+
const validated = getPartialSchema(model).parse(workingData);
|
|
385
404
|
|
|
386
405
|
// Emit afterValidation
|
|
387
406
|
ModelEvents.emit(model.name, Hooks.AFTER_VALIDATION, validated, ctx);
|
|
@@ -438,7 +457,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
438
457
|
*/
|
|
439
458
|
async function updateWhere(conditions, data) {
|
|
440
459
|
// Validate with Zod schema (partial for update)
|
|
441
|
-
const validated = model
|
|
460
|
+
const validated = getPartialSchema(model).parse(data);
|
|
442
461
|
|
|
443
462
|
// Apply update modifiers (timestamps)
|
|
444
463
|
let updateData = applyUpdateModifiers(validated, model);
|
|
@@ -493,9 +512,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
493
512
|
const parentVal = record[localKey];
|
|
494
513
|
if (parentVal === undefined || parentVal === null) continue;
|
|
495
514
|
|
|
515
|
+
const deletedCol = getSoftDeleteColumn(relatedModel);
|
|
496
516
|
let childQuery = activeKnex(relatedModel.table)
|
|
497
517
|
.where(foreignKey, parentVal)
|
|
498
|
-
.whereNull(
|
|
518
|
+
.whereNull(deletedCol);
|
|
499
519
|
|
|
500
520
|
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
501
521
|
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
@@ -508,7 +528,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
508
528
|
|
|
509
529
|
await activeKnex(relatedModel.table)
|
|
510
530
|
.whereIn(relatedModel.primaryKey, childIds)
|
|
511
|
-
.update(getSoftDeleteData(deletedAt));
|
|
531
|
+
.update(getSoftDeleteData(deletedAt, relatedModel));
|
|
512
532
|
|
|
513
533
|
if (cacheLayer) {
|
|
514
534
|
cacheLayer.invalidateModelAll(relatedModel);
|
|
@@ -637,9 +657,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
637
657
|
const parentVal = trashedRecord[localKey];
|
|
638
658
|
if (parentVal === undefined || parentVal === null) continue;
|
|
639
659
|
|
|
660
|
+
const deletedCol = getSoftDeleteColumn(relatedModel);
|
|
640
661
|
let childQuery = activeKnex(relatedModel.table)
|
|
641
662
|
.where(foreignKey, parentVal)
|
|
642
|
-
.whereNotNull(
|
|
663
|
+
.whereNotNull(deletedCol);
|
|
643
664
|
|
|
644
665
|
if (scopeContext && scopeContext.tenantId !== undefined && relatedModel.scopes.tenant) {
|
|
645
666
|
childQuery = childQuery.where(relatedModel.scopes.tenant, scopeContext.tenantId);
|
|
@@ -647,7 +668,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
647
668
|
|
|
648
669
|
// Selective restore by matching deletion timestamp
|
|
649
670
|
if (!options.allChildren && parentDeletedAt != null) {
|
|
650
|
-
childQuery = childQuery.where(
|
|
671
|
+
childQuery = childQuery.where(deletedCol, parentDeletedAt);
|
|
651
672
|
}
|
|
652
673
|
|
|
653
674
|
const children = await childQuery.select('*');
|
|
@@ -658,14 +679,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
658
679
|
);
|
|
659
680
|
if (hasDeeper) {
|
|
660
681
|
for (const child of children) {
|
|
661
|
-
|
|
682
|
+
const childDeletedAt = child[deletedCol] || child.deleted_at || parentDeletedAt;
|
|
683
|
+
await cascadeRestore(relatedModel, child, childDeletedAt, options, visited);
|
|
662
684
|
}
|
|
663
685
|
}
|
|
664
686
|
|
|
665
687
|
const childIds = children.map(c => c[relatedModel.primaryKey]);
|
|
666
688
|
await activeKnex(relatedModel.table)
|
|
667
689
|
.whereIn(relatedModel.primaryKey, childIds)
|
|
668
|
-
.update(getRestoreData());
|
|
690
|
+
.update(getRestoreData(relatedModel));
|
|
669
691
|
|
|
670
692
|
if (cacheLayer) {
|
|
671
693
|
cacheLayer.invalidateModelAll(relatedModel);
|
|
@@ -674,7 +696,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
674
696
|
if (ModelEvents.hasListeners(relatedModel.name, Hooks.AFTER_RESTORE)) {
|
|
675
697
|
for (const child of children) {
|
|
676
698
|
const childCtx = createEventContext(relatedModel.name, 'restore', getHookTrx());
|
|
677
|
-
ModelEvents.emit(relatedModel.name, Hooks.AFTER_RESTORE, { ...child, deleted_at: null }, childCtx);
|
|
699
|
+
ModelEvents.emit(relatedModel.name, Hooks.AFTER_RESTORE, { ...child, [deletedCol]: null, deleted_at: null }, childCtx);
|
|
678
700
|
}
|
|
679
701
|
}
|
|
680
702
|
}
|
|
@@ -708,7 +730,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
708
730
|
if (model.scopes.softDelete) {
|
|
709
731
|
const updated = await baseQuery()
|
|
710
732
|
.where(model.primaryKey, id)
|
|
711
|
-
.update(getSoftDeleteData(deletedAt));
|
|
733
|
+
.update(getSoftDeleteData(deletedAt, model));
|
|
712
734
|
success = updated > 0;
|
|
713
735
|
|
|
714
736
|
if (success) {
|
|
@@ -791,10 +813,12 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
791
813
|
|
|
792
814
|
const ctx = createEventContext(model.name, 'restore', getHookTrx());
|
|
793
815
|
|
|
816
|
+
const deletedCol = getSoftDeleteColumn(model);
|
|
817
|
+
|
|
794
818
|
// Get the trashed record for hooks (bypass soft delete scope)
|
|
795
819
|
const trashedRecord = await getActiveKnex()(model.table)
|
|
796
820
|
.where(model.primaryKey, id)
|
|
797
|
-
.whereNotNull(
|
|
821
|
+
.whereNotNull(deletedCol)
|
|
798
822
|
.first();
|
|
799
823
|
|
|
800
824
|
if (!trashedRecord) {
|
|
@@ -810,8 +834,8 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
810
834
|
// Update directly without scopes (to find trashed record)
|
|
811
835
|
const updated = await getActiveKnex()(model.table)
|
|
812
836
|
.where(model.primaryKey, id)
|
|
813
|
-
.whereNotNull(
|
|
814
|
-
.update(getRestoreData());
|
|
837
|
+
.whereNotNull(deletedCol)
|
|
838
|
+
.update(getRestoreData(model));
|
|
815
839
|
|
|
816
840
|
if (updated === 0) {
|
|
817
841
|
return null;
|
|
@@ -822,7 +846,8 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
822
846
|
}
|
|
823
847
|
|
|
824
848
|
if (!options || options.cascade !== false) {
|
|
825
|
-
|
|
849
|
+
const parentDeletedAt = trashedRecord[deletedCol] || trashedRecord.deleted_at;
|
|
850
|
+
await cascadeRestore(model, trashedRecord, parentDeletedAt, options);
|
|
826
851
|
}
|
|
827
852
|
|
|
828
853
|
const record = await findById(id, {}, false);
|
|
@@ -838,7 +863,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
|
|
|
838
863
|
* @returns {import('./query-builder').QueryBuilder}
|
|
839
864
|
*/
|
|
840
865
|
function query() {
|
|
841
|
-
return createQueryBuilder(model, getActiveKnex(), scopeContext, cacheLayer);
|
|
866
|
+
return createQueryBuilder(model, getActiveKnex(), { ...scopeContext }, cacheLayer);
|
|
842
867
|
}
|
|
843
868
|
|
|
844
869
|
/**
|
|
@@ -778,6 +778,55 @@ function createSchemaHelpers(z) {
|
|
|
778
778
|
...rest,
|
|
779
779
|
}, z);
|
|
780
780
|
},
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Timestamp pair columns (createdAt and updatedAt)
|
|
784
|
+
* @param {Object} [options={}]
|
|
785
|
+
* @param {boolean} [options.snakeCase=false] - Use snake_case (created_at, updated_at)
|
|
786
|
+
* @param {string} [options.createdAt] - Custom created column name
|
|
787
|
+
* @param {string} [options.updatedAt] - Custom updated column name
|
|
788
|
+
* @returns {{ [key: string]: SchemaBuilder }}
|
|
789
|
+
*/
|
|
790
|
+
timestamps(options = {}) {
|
|
791
|
+
const isSnake = options.snakeCase || options.format === 'snake';
|
|
792
|
+
const createdKey = options.createdAt || (isSnake ? 'created_at' : 'createdAt');
|
|
793
|
+
const updatedKey = options.updatedAt || (isSnake ? 'updated_at' : 'updatedAt');
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
[createdKey]: helpers.timestamp({ auto: 'create', ...options }),
|
|
797
|
+
[updatedKey]: helpers.timestamp({ auto: 'update', ...options }),
|
|
798
|
+
};
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Soft delete column helper (returns a single SchemaBuilder for deletedAt/deleted_at)
|
|
803
|
+
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
804
|
+
* @returns {SchemaBuilder}
|
|
805
|
+
*/
|
|
806
|
+
softDelete(options = {}) {
|
|
807
|
+
return helpers.timestamp({
|
|
808
|
+
nullable: true,
|
|
809
|
+
...options,
|
|
810
|
+
});
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Soft delete field object (returns { deletedAt: SchemaBuilder } or { deleted_at: SchemaBuilder })
|
|
815
|
+
* @param {Object} [options={}]
|
|
816
|
+
* @param {boolean} [options.snakeCase=false] - Use snake_case (deleted_at)
|
|
817
|
+
* @param {string} [options.deletedAt] - Custom deleted column name
|
|
818
|
+
* @returns {{ [key: string]: SchemaBuilder }}
|
|
819
|
+
*/
|
|
820
|
+
softDeletes(options = {}) {
|
|
821
|
+
const isSnake = options.snakeCase || options.format === 'snake';
|
|
822
|
+
const deletedKey = options.deletedAt || (isSnake ? 'deleted_at' : 'deletedAt');
|
|
823
|
+
return {
|
|
824
|
+
[deletedKey]: helpers.timestamp({
|
|
825
|
+
nullable: true,
|
|
826
|
+
...options,
|
|
827
|
+
}),
|
|
828
|
+
};
|
|
829
|
+
},
|
|
781
830
|
};
|
|
782
831
|
|
|
783
832
|
return helpers;
|
package/core/orm/scopes.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
const { generateNanoid } = require('./utils/nanoid');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Determine the soft delete column name for a model
|
|
11
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
12
|
+
* @returns {string} Column name ('deletedAt' or 'deleted_at')
|
|
13
|
+
*/
|
|
14
|
+
function getSoftDeleteColumn(model) {
|
|
15
|
+
return (model && model.columns && model.columns.has('deletedAt')) ? 'deletedAt' : 'deleted_at';
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* Apply soft delete scope to a query builder
|
|
11
20
|
* @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
|
|
@@ -18,9 +27,11 @@ function applySoftDeleteScope(qb, context, model) {
|
|
|
18
27
|
return qb;
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
const deletedCol = getSoftDeleteColumn(model);
|
|
31
|
+
|
|
21
32
|
// If onlyTrashed is set, only return deleted records
|
|
22
33
|
if (context.onlyTrashed) {
|
|
23
|
-
return qb.whereNotNull(
|
|
34
|
+
return qb.whereNotNull(deletedCol);
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
// If withTrashed is set, return all records (no filter)
|
|
@@ -29,7 +40,7 @@ function applySoftDeleteScope(qb, context, model) {
|
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
// Default: only return non-deleted records
|
|
32
|
-
return qb.whereNull(
|
|
43
|
+
return qb.whereNull(deletedCol);
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -74,10 +85,13 @@ function applyInsertTimestamps(data, model) {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
const now = new Date();
|
|
88
|
+
const createdCol = (model.columns && model.columns.has('createdAt')) ? 'createdAt' : 'created_at';
|
|
89
|
+
const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
|
|
90
|
+
|
|
77
91
|
return {
|
|
78
92
|
...data,
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
[createdCol]: data[createdCol] !== undefined ? data[createdCol] : (data.createdAt || data.created_at || now),
|
|
94
|
+
[updatedCol]: data[updatedCol] !== undefined ? data[updatedCol] : (data.updatedAt || data.updated_at || now),
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
|
|
@@ -92,9 +106,11 @@ function applyUpdateTimestamps(data, model) {
|
|
|
92
106
|
return data;
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
|
|
110
|
+
|
|
95
111
|
return {
|
|
96
112
|
...data,
|
|
97
|
-
|
|
113
|
+
[updatedCol]: new Date(),
|
|
98
114
|
};
|
|
99
115
|
}
|
|
100
116
|
|
|
@@ -143,18 +159,32 @@ function applyInsertNanoidPrimary(data, model) {
|
|
|
143
159
|
/**
|
|
144
160
|
* Get soft delete data (for UPDATE instead of DELETE)
|
|
145
161
|
* @param {Date|string|number} [date=new Date()] - Soft delete timestamp
|
|
162
|
+
* @param {string|import('./types').ModelDefinition} [colOrModel='deleted_at'] - Column name or model definition
|
|
146
163
|
* @returns {Object} Soft delete update data
|
|
147
164
|
*/
|
|
148
|
-
function getSoftDeleteData(date = new Date()) {
|
|
149
|
-
|
|
165
|
+
function getSoftDeleteData(date = new Date(), colOrModel = 'deleted_at') {
|
|
166
|
+
let columnName = 'deleted_at';
|
|
167
|
+
if (typeof colOrModel === 'string') {
|
|
168
|
+
columnName = colOrModel;
|
|
169
|
+
} else if (colOrModel && typeof colOrModel === 'object') {
|
|
170
|
+
columnName = getSoftDeleteColumn(colOrModel);
|
|
171
|
+
}
|
|
172
|
+
return { [columnName]: date instanceof Date ? date : new Date(date) };
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
/**
|
|
153
176
|
* Get restore data (to undo soft delete)
|
|
177
|
+
* @param {string|import('./types').ModelDefinition} [colOrModel='deleted_at'] - Column name or model definition
|
|
154
178
|
* @returns {Object} Restore update data
|
|
155
179
|
*/
|
|
156
|
-
function getRestoreData() {
|
|
157
|
-
|
|
180
|
+
function getRestoreData(colOrModel = 'deleted_at') {
|
|
181
|
+
let columnName = 'deleted_at';
|
|
182
|
+
if (typeof colOrModel === 'string') {
|
|
183
|
+
columnName = colOrModel;
|
|
184
|
+
} else if (colOrModel && typeof colOrModel === 'object') {
|
|
185
|
+
columnName = getSoftDeleteColumn(colOrModel);
|
|
186
|
+
}
|
|
187
|
+
return { [columnName]: null };
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
/**
|
|
@@ -207,6 +237,7 @@ module.exports = {
|
|
|
207
237
|
applyUpdateModifiers,
|
|
208
238
|
getSoftDeleteData,
|
|
209
239
|
getRestoreData,
|
|
240
|
+
getSoftDeleteColumn,
|
|
210
241
|
createScopeContext,
|
|
211
242
|
};
|
|
212
243
|
|