webspresso 0.0.102 → 0.0.104

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 (91) 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/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Webspresso ORM - Universal Query Filter Engine
3
+ *
4
+ * Unified column filtering engine powering both REST Resources and Admin Panel.
5
+ * Supports comparison operators, pattern matching, sets, ranges, nullability,
6
+ * and column type coercion.
7
+ *
8
+ * @module core/orm/query-filters
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const RESERVED_QUERY_KEYS = new Set([
14
+ 'page',
15
+ 'perPage',
16
+ 'per_page',
17
+ 'limit',
18
+ 'offset',
19
+ 'sort',
20
+ 'order',
21
+ 'include',
22
+ 'trashed',
23
+ 'fields',
24
+ 'select',
25
+ 'search',
26
+ 'q',
27
+ ]);
28
+
29
+ /**
30
+ * Normalizes an operator name to canonical form
31
+ * @param {string} op
32
+ * @returns {string}
33
+ */
34
+ function normalizeOperator(op) {
35
+ const s = String(op || 'contains').toLowerCase().trim();
36
+ switch (s) {
37
+ case 'equals':
38
+ case '=':
39
+ case 'eq':
40
+ return 'eq';
41
+
42
+ case 'ne':
43
+ case '!=':
44
+ case '<>':
45
+ case 'not_equals':
46
+ case 'notequals':
47
+ return 'ne';
48
+
49
+ case 'gt':
50
+ case '>':
51
+ return 'gt';
52
+
53
+ case 'gte':
54
+ case '>=':
55
+ return 'gte';
56
+
57
+ case 'lt':
58
+ case '<':
59
+ return 'lt';
60
+
61
+ case 'lte':
62
+ case '<=':
63
+ return 'lte';
64
+
65
+ case 'in':
66
+ return 'in';
67
+
68
+ case 'nin':
69
+ case 'notin':
70
+ case 'not_in':
71
+ return 'notin';
72
+
73
+ case 'like':
74
+ return 'like';
75
+
76
+ case 'ilike':
77
+ return 'ilike';
78
+
79
+ case 'contains':
80
+ return 'contains';
81
+
82
+ case 'starts_with':
83
+ case 'startswith':
84
+ return 'startswith';
85
+
86
+ case 'ends_with':
87
+ case 'endswith':
88
+ return 'endswith';
89
+
90
+ case 'is_null':
91
+ case 'isnull':
92
+ case 'null':
93
+ return 'is_null';
94
+
95
+ case 'is_not_null':
96
+ case 'isnotnull':
97
+ case 'not_null':
98
+ case 'notnull':
99
+ return 'is_not_null';
100
+
101
+ case 'between':
102
+ return 'between';
103
+
104
+ default:
105
+ return s;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Applies a single filter condition to one or more query builder instances
111
+ *
112
+ * @param {Array<Object>} targets - Query builder instances to mutate
113
+ * @param {string} column - Target column name
114
+ * @param {string} rawOp - Operator name
115
+ * @param {unknown} rawVal - Value or object containing from/to
116
+ * @param {Object} [meta] - Optional column metadata from model definition
117
+ */
118
+ function applyFilterCondition(targets, column, rawOp, rawVal, meta = null) {
119
+ if (!targets || targets.length === 0) return;
120
+
121
+ const colType = meta?.type;
122
+ const operator = normalizeOperator(rawOp);
123
+ let val = rawVal;
124
+
125
+ // Handle boolean column type coercion
126
+ if (colType === 'boolean' && typeof val === 'string') {
127
+ if (val === 'true' || val === '1') {
128
+ val = true;
129
+ } else if (val === 'false' || val === '0') {
130
+ val = false;
131
+ }
132
+ }
133
+
134
+ // Handle nullability operators
135
+ if (operator === 'is_null') {
136
+ const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
137
+ if (isTrue) {
138
+ for (const t of targets) t.whereNull(column);
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (operator === 'is_not_null') {
144
+ const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
145
+ if (isTrue) {
146
+ for (const t of targets) t.whereNotNull(column);
147
+ }
148
+ return;
149
+ }
150
+
151
+ // Handle between operator
152
+ if (operator === 'between') {
153
+ let from = undefined;
154
+ let to = undefined;
155
+
156
+ if (Array.isArray(val)) {
157
+ from = val[0];
158
+ to = val[1];
159
+ } else if (typeof val === 'string') {
160
+ const parts = val.split(',').map((s) => s.trim());
161
+ from = parts[0];
162
+ to = parts[1];
163
+ } else if (val && typeof val === 'object') {
164
+ from = val.from;
165
+ to = val.to;
166
+ }
167
+
168
+ const hasFrom = from !== undefined && from !== null && from !== '';
169
+ const hasTo = to !== undefined && to !== null && to !== '';
170
+
171
+ if (hasFrom && hasTo) {
172
+ for (const t of targets) {
173
+ t.where(column, '>=', from);
174
+ t.where(column, '<=', to);
175
+ }
176
+ } else if (hasFrom) {
177
+ for (const t of targets) t.where(column, '>=', from);
178
+ } else if (hasTo) {
179
+ for (const t of targets) t.where(column, '<=', to);
180
+ }
181
+ return;
182
+ }
183
+
184
+ // Handle in & notin
185
+ if (operator === 'in') {
186
+ const arr = Array.isArray(val)
187
+ ? val
188
+ : (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
189
+ if (arr.length > 0) {
190
+ for (const t of targets) t.whereIn(column, arr);
191
+ }
192
+ return;
193
+ }
194
+
195
+ if (operator === 'notin') {
196
+ const arr = Array.isArray(val)
197
+ ? val
198
+ : (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
199
+ if (arr.length > 0) {
200
+ for (const t of targets) t.whereNotIn(column, arr);
201
+ }
202
+ return;
203
+ }
204
+
205
+ // Ignore inactive empty values for standard scalar operators
206
+ if (val === undefined || val === null || val === '') {
207
+ return;
208
+ }
209
+
210
+ switch (operator) {
211
+ case 'contains':
212
+ for (const t of targets) t.where(column, 'like', `%${val}%`);
213
+ break;
214
+
215
+ case 'startswith':
216
+ for (const t of targets) t.where(column, 'like', `${val}%`);
217
+ break;
218
+
219
+ case 'endswith':
220
+ for (const t of targets) t.where(column, 'like', `%${val}`);
221
+ break;
222
+
223
+ case 'like':
224
+ for (const t of targets) t.where(column, 'like', String(val));
225
+ break;
226
+
227
+ case 'ilike':
228
+ for (const t of targets) t.where(column, 'ilike', String(val));
229
+ break;
230
+
231
+ case 'gt':
232
+ for (const t of targets) t.where(column, '>', val);
233
+ break;
234
+
235
+ case 'gte':
236
+ for (const t of targets) t.where(column, '>=', val);
237
+ break;
238
+
239
+ case 'lt':
240
+ for (const t of targets) t.where(column, '<', val);
241
+ break;
242
+
243
+ case 'lte':
244
+ for (const t of targets) t.where(column, '<=', val);
245
+ break;
246
+
247
+ case 'ne':
248
+ for (const t of targets) t.where(column, '!=', val);
249
+ break;
250
+
251
+ case 'eq':
252
+ default:
253
+ for (const t of targets) t.where(column, '=', val);
254
+ break;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Builds a query with filters applied (used by Admin Panel and Repositories)
260
+ *
261
+ * @param {Object} repoOrQuery - Repository instance or Knex QueryBuilder
262
+ * @param {Object} filters - Filter object: { column: { op, value, from, to } } or { column: value }
263
+ * @param {Object} [options] - Options
264
+ * @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
265
+ * @param {Object} [options.model] - Optional ModelDefinition for column verification
266
+ * @returns {Object} Query builder with filters applied
267
+ */
268
+ function buildFilteredQuery(repoOrQuery, filters, options = {}) {
269
+ let query = (repoOrQuery && typeof repoOrQuery.query === 'function')
270
+ ? repoOrQuery.query()
271
+ : repoOrQuery;
272
+
273
+ if (options.onlyTrashed && query && typeof query.onlyTrashed === 'function') {
274
+ query = query.onlyTrashed();
275
+ }
276
+
277
+ if (!filters || typeof filters !== 'object' || Object.keys(filters).length === 0) {
278
+ return query;
279
+ }
280
+
281
+ const model = options.model || (repoOrQuery && repoOrQuery.model);
282
+ const columns = model?.columns;
283
+
284
+ for (const [column, filter] of Object.entries(filters)) {
285
+ if (!filter) continue;
286
+ if (columns && typeof columns.has === 'function' && !columns.has(column)) {
287
+ continue;
288
+ }
289
+
290
+ const meta = columns && typeof columns.get === 'function' ? columns.get(column) : null;
291
+
292
+ if (typeof filter === 'object' && !Array.isArray(filter)) {
293
+ const rawOp = filter.op || filter.operator;
294
+ const normOp = rawOp ? normalizeOperator(rawOp) : null;
295
+ if (normOp === 'is_null' || normOp === 'is_not_null') {
296
+ applyFilterCondition([query], column, normOp, filter.value !== undefined ? filter.value : true, meta);
297
+ } else if (normOp === 'between') {
298
+ applyFilterCondition([query], column, rawOp, filter, meta);
299
+ } else if (filter.value !== undefined) {
300
+ applyFilterCondition([query], column, rawOp || 'contains', filter.value, meta);
301
+ } else {
302
+ for (const [nestedOp, nestedVal] of Object.entries(filter)) {
303
+ applyFilterCondition([query], column, nestedOp, nestedVal, meta);
304
+ }
305
+ }
306
+ } else {
307
+ applyFilterCondition([query], column, 'eq', filter, meta);
308
+ }
309
+ }
310
+
311
+ return query;
312
+ }
313
+
314
+ /**
315
+ * Extracts and applies column filters from an HTTP request or filter object to query builders
316
+ * (used by REST Resources and API routes)
317
+ *
318
+ * @param {Object} query - Main QueryBuilder
319
+ * @param {Object} [countQuery] - Optional Count QueryBuilder
320
+ * @param {import('./types').ModelDefinition} model - Model definition
321
+ * @param {import('express').Request|Object} reqOrQuery - Express request or query object
322
+ * @returns {{ query: Object, countQuery: Object }} Mutated query instances
323
+ */
324
+ function applyColumnFilters(query, countQuery, model, reqOrQuery) {
325
+ const q = query;
326
+ const c = countQuery;
327
+ const targets = [q, c].filter(Boolean);
328
+
329
+ const rawQuery = (reqOrQuery && typeof reqOrQuery === 'object' && 'query' in reqOrQuery)
330
+ ? reqOrQuery.query
331
+ : (reqOrQuery || {});
332
+
333
+ if (!rawQuery || typeof rawQuery !== 'object') {
334
+ return { query: q, countQuery: c };
335
+ }
336
+
337
+ const hasColumns = model && model.columns && typeof model.columns.has === 'function';
338
+
339
+ // 1. Process dedicated filter object: ?filter[col]=... or ?filter[col][op]=... or ?filter={ col: { op, value } }
340
+ if (rawQuery.filter && typeof rawQuery.filter === 'object') {
341
+ for (const [col, colFilter] of Object.entries(rawQuery.filter)) {
342
+ if (hasColumns && !model.columns.has(col)) continue;
343
+ const meta = hasColumns ? model.columns.get(col) : null;
344
+
345
+ if (colFilter && typeof colFilter === 'object' && !Array.isArray(colFilter)) {
346
+ const rawColOp = colFilter.op || colFilter.operator;
347
+ const normColOp = rawColOp ? normalizeOperator(rawColOp) : null;
348
+ if (normColOp === 'is_null' || normColOp === 'is_not_null') {
349
+ applyFilterCondition(targets, col, normColOp, colFilter.value !== undefined ? colFilter.value : true, meta);
350
+ } else if (colFilter.op !== undefined && colFilter.value !== undefined) {
351
+ applyFilterCondition(targets, col, colFilter.op, colFilter.value, meta);
352
+ } else if (colFilter.from !== undefined || colFilter.to !== undefined) {
353
+ applyFilterCondition(targets, col, colFilter.op || 'between', colFilter, meta);
354
+ } else {
355
+ for (const [op, val] of Object.entries(colFilter)) {
356
+ applyFilterCondition(targets, col, op, val, meta);
357
+ }
358
+ }
359
+ } else if (colFilter !== undefined && colFilter !== '') {
360
+ applyFilterCondition(targets, col, 'eq', colFilter, meta);
361
+ }
362
+ }
363
+ }
364
+
365
+ // 2. Process top-level query parameters: ?price[gte]=100 or ?status=active
366
+ for (const [key, value] of Object.entries(rawQuery)) {
367
+ if (RESERVED_QUERY_KEYS.has(key) || key === 'filter') {
368
+ continue;
369
+ }
370
+ if (hasColumns && !model.columns.has(key)) {
371
+ continue;
372
+ }
373
+ const meta = hasColumns ? model.columns.get(key) : null;
374
+
375
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
376
+ for (const [op, val] of Object.entries(value)) {
377
+ applyFilterCondition(targets, key, op, val, meta);
378
+ }
379
+ } else if (value !== undefined && value !== '') {
380
+ applyFilterCondition(targets, key, 'eq', value, meta);
381
+ }
382
+ }
383
+
384
+ return { query: q, countQuery: c };
385
+ }
386
+
387
+ module.exports = {
388
+ RESERVED_QUERY_KEYS,
389
+ normalizeOperator,
390
+ applyFilterCondition,
391
+ buildFilteredQuery,
392
+ applyColumnFilters,
393
+ };
@@ -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;