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.
Files changed (39) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/core/auth/jwt.js +10 -17
  5. package/core/auth/user-agent.js +61 -42
  6. package/core/compression/compression-stream.js +2 -2
  7. package/core/compression/middleware.js +1 -1
  8. package/core/compression/negotiate.js +11 -4
  9. package/core/events/dispatcher.js +56 -33
  10. package/core/openapi/orm-components.js +2 -3
  11. package/core/orm/cache/listeners.js +12 -2
  12. package/core/orm/json-fields.js +7 -1
  13. package/core/orm/model.js +99 -8
  14. package/core/orm/query-builder.js +71 -25
  15. package/core/orm/repository.js +42 -17
  16. package/core/orm/schema-helpers.js +49 -0
  17. package/core/orm/scopes.js +40 -9
  18. package/core/orm/types.js +12 -1
  19. package/core/queue/manager.js +5 -0
  20. package/core/validation/index.js +9 -3
  21. package/index.d.ts +22 -5
  22. package/package.json +2 -1
  23. package/plugins/audit-log/middleware.js +39 -4
  24. package/plugins/email/service.js +32 -1
  25. package/plugins/file-manager/api-handlers.js +10 -17
  26. package/plugins/file-manager/index.js +11 -9
  27. package/plugins/file-manager/security.js +18 -6
  28. package/plugins/site-analytics/bot-patterns.js +3 -0
  29. package/plugins/site-analytics/client-error-handler.js +3 -1
  30. package/plugins/site-analytics/index.js +10 -1
  31. package/plugins/site-analytics/tracking.js +245 -46
  32. package/src/api/api-loader.js +1 -1
  33. package/src/discovery/scan-routes.js +14 -4
  34. package/src/file-router.js +62 -6
  35. package/src/helpers.js +139 -26
  36. package/src/plugin-manager.js +34 -13
  37. package/src/routing/route-table.js +14 -4
  38. package/src/server.js +23 -7
  39. 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
- let normalizedSchema = schema;
51
- if (schema && schema.shape) {
52
- for (const [key, val] of Object.entries(schema.shape)) {
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
- schema.shape[key] = val._finalize();
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: scopes.softDelete || false,
112
- timestamps: scopes.timestamps || false,
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 || createScopeContext();
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 [col, val] of Object.entries(columnOrConditions)) {
81
+ for (const col of Object.keys(columnOrConditions)) {
82
82
  this.state.wheres.push({
83
83
  column: col,
84
84
  operator: '=',
85
- value: val,
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 ctx = createEventContext(this.model.name, 'find', this._hookTrx());
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
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
485
- if (ctx.isCancelled) {
486
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
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
- deserializeJsonFields(result, self.jsonColumns);
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
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
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 ctx = createEventContext(this.model.name, 'find', this._hookTrx());
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
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
551
- if (ctx.isCancelled) {
552
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
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
- for (const record of results) {
558
- deserializeJsonFields(record, self.jsonColumns);
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
- for (const record of results) {
567
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
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 ctx = createEventContext(this.model.name, 'find', this._hookTrx());
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
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
684
- if (ctx.isCancelled) {
685
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
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
- for (const record of data) {
697
- deserializeJsonFields(record, self.jsonColumns);
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
- for (const record of data) {
706
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
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 {
@@ -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 || createScopeContext();
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.schema.partial().parse(workingData);
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.schema.partial().parse(workingData);
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.schema.partial().parse(data);
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('deleted_at');
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('deleted_at');
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('deleted_at', parentDeletedAt);
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
- await cascadeRestore(relatedModel, child, child.deleted_at || parentDeletedAt, options, visited);
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('deleted_at')
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('deleted_at')
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
- await cascadeRestore(model, trashedRecord, trashedRecord.deleted_at, options);
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;
@@ -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('deleted_at');
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('deleted_at');
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
- created_at: data.created_at || now,
80
- updated_at: data.updated_at || now,
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
- updated_at: new Date(),
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
- return { deleted_at: date instanceof Date ? date : new Date(date) };
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
- return { deleted_at: null };
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