webspresso 0.0.101 → 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 (62) hide show
  1. package/.agents/AGENTS.md +1 -1
  2. package/.agents/ARCHITECTURE.md +11 -0
  3. package/.agents/CONVENTIONS.md +4 -0
  4. package/.agents/ORM.md +13 -4
  5. package/.agents/PLUGINS.md +32 -6
  6. package/.agents/ROUTING.md +99 -2
  7. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  10. package/AGENTS.md +6 -0
  11. package/README.md +20 -7
  12. package/bin/commands/add-tailwind.js +10 -0
  13. package/bin/commands/build.js +34 -0
  14. package/bin/commands/routes.js +8 -0
  15. package/core/applySchema.js +18 -1
  16. package/core/auth/jwt.js +10 -17
  17. package/core/auth/user-agent.js +61 -42
  18. package/core/compression/compression-stream.js +2 -2
  19. package/core/compression/middleware.js +1 -1
  20. package/core/compression/negotiate.js +11 -4
  21. package/core/events/dispatcher.js +56 -33
  22. package/core/openapi/orm-components.js +2 -3
  23. package/core/orm/cache/listeners.js +12 -2
  24. package/core/orm/json-fields.js +7 -1
  25. package/core/orm/model.js +99 -8
  26. package/core/orm/query-builder.js +71 -25
  27. package/core/orm/repository.js +42 -17
  28. package/core/orm/schema-helpers.js +49 -0
  29. package/core/orm/scopes.js +40 -9
  30. package/core/orm/types.js +12 -1
  31. package/core/queue/manager.js +5 -0
  32. package/core/validation/index.js +9 -3
  33. package/index.d.ts +178 -6
  34. package/index.js +71 -16
  35. package/package.json +6 -5
  36. package/plugins/admin-panel/index.js +12 -0
  37. package/plugins/audit-log/middleware.js +39 -4
  38. package/plugins/csrf/index.js +1 -1
  39. package/plugins/email/service.js +32 -1
  40. package/plugins/file-manager/api-handlers.js +10 -17
  41. package/plugins/file-manager/index.js +24 -9
  42. package/plugins/file-manager/security.js +18 -6
  43. package/plugins/index.js +2 -0
  44. package/plugins/lazy.js +258 -0
  45. package/plugins/site-analytics/bot-patterns.js +3 -0
  46. package/plugins/site-analytics/client-error-handler.js +3 -1
  47. package/plugins/site-analytics/index.js +10 -1
  48. package/plugins/site-analytics/tracking.js +245 -46
  49. package/src/api/api-loader.js +18 -2
  50. package/src/api/validation-i18n.js +231 -0
  51. package/src/discovery/route-manifest.js +197 -0
  52. package/src/discovery/scan-routes.js +84 -8
  53. package/src/file-router.js +215 -97
  54. package/src/helpers.js +139 -26
  55. package/src/njk-frontmatter.js +17 -1
  56. package/src/plugin-manager.js +46 -13
  57. package/src/routing/route-table.js +15 -5
  58. package/src/server.js +86 -15
  59. package/src/services/builtins/system.js +4 -6
  60. package/src/services/registry.js +12 -2
  61. package/src/services/validator.js +57 -32
  62. package/src/template/precompile.js +247 -0
@@ -39,6 +39,28 @@ function getClientIp(req) {
39
39
  '127.0.0.1';
40
40
  }
41
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
+ const MAX_UA_CACHE = 512;
50
+ const uaCache = new Map();
51
+
52
+ const EMPTY_DEVICE_INFO = Object.freeze({
53
+ browser: 'Unknown',
54
+ os: 'Unknown',
55
+ device: 'unknown',
56
+ isMobile: false,
57
+ isTablet: false,
58
+ isDesktop: false,
59
+ isBot: false,
60
+ });
61
+
62
+ const BOT_PATTERN = /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;
63
+
42
64
  /**
43
65
  * Parse raw User-Agent header string into structured device info
44
66
  * Zero external dependencies — pure regex extraction
@@ -50,34 +72,30 @@ function parseUserAgent(userAgent = '') {
50
72
  const ua = String(userAgent || '').trim();
51
73
 
52
74
  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
- };
75
+ return { ...EMPTY_DEVICE_INFO };
76
+ }
77
+
78
+ const cached = uaCache.get(ua);
79
+ if (cached) {
80
+ return { ...cached };
62
81
  }
63
82
 
64
83
  // 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);
84
+ const isBot = BOT_PATTERN.test(ua);
67
85
 
68
86
  // 2. Detect Operating System
69
87
  let os = 'Unknown';
88
+ let match;
70
89
  if (/iPad/i.test(ua)) {
71
- const match = ua.match(/OS ([\d_\.]+)/i);
90
+ match = ua.match(/OS ([\d_\.]+)/i);
72
91
  os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
73
92
  } else if (/iPhone|iPod/i.test(ua)) {
74
- const match = ua.match(/OS ([\d_\.]+)/i);
93
+ match = ua.match(/OS ([\d_\.]+)/i);
75
94
  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';
95
+ } else if ((match = ua.match(/Android (\d+(\.\d+)?)/i))) {
96
+ os = `Android ${match[1]}`;
79
97
  } else if (/Macintosh|Mac OS X/i.test(ua)) {
80
- const match = ua.match(/Mac OS X ([\d_\.]+)/i);
98
+ match = ua.match(/Mac OS X ([\d_\.]+)/i);
81
99
  if (match) {
82
100
  const ver = match[1].replace(/_/g, '.');
83
101
  os = `macOS ${ver}`;
@@ -100,30 +118,22 @@ function parseUserAgent(userAgent = '') {
100
118
 
101
119
  // 3. Detect Browser
102
120
  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();
121
+ if ((match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i))) {
122
+ browser = `Edge ${match[2] || ''}`.trim();
123
+ } else if ((match = ua.match(/OPR\/(\d+)/i))) {
124
+ browser = `Opera ${match[1] || ''}`.trim();
125
+ } else if ((match = ua.match(/SamsungBrowser\/(\d+)/i))) {
126
+ browser = `Samsung Internet ${match[1] || ''}`.trim();
127
+ } else if ((match = ua.match(/Brave\/(\d+)/i))) {
128
+ browser = `Brave ${match[1] || ''}`.trim();
129
+ } else if ((match = ua.match(/Vivaldi\/(\d+)/i))) {
130
+ browser = `Vivaldi ${match[1] || ''}`.trim();
131
+ } else if (!/Chromium/i.test(ua) && (match = ua.match(/Chrome\/(\d+)/i))) {
132
+ browser = `Chrome ${match[1] || ''}`.trim();
133
+ } else if ((match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i))) {
134
+ browser = `Firefox ${match[1] || ''}`.trim();
135
+ } else if ((match = ua.match(/Version\/(\d+)/i)) && /Safari/i.test(ua)) {
136
+ browser = `Safari ${match[1] || ''}`.trim();
127
137
  } else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
128
138
  browser = 'Safari';
129
139
  } else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
@@ -144,7 +154,7 @@ function parseUserAgent(userAgent = '') {
144
154
  else if (isMobile) device = 'mobile';
145
155
  else if (isDesktop) device = 'desktop';
146
156
 
147
- return {
157
+ const result = {
148
158
  browser,
149
159
  os,
150
160
  device,
@@ -153,9 +163,18 @@ function parseUserAgent(userAgent = '') {
153
163
  isDesktop,
154
164
  isBot,
155
165
  };
166
+
167
+ if (uaCache.size >= MAX_UA_CACHE) {
168
+ const oldestKey = uaCache.keys().next().value;
169
+ uaCache.delete(oldestKey);
170
+ }
171
+ uaCache.set(ua, result);
172
+
173
+ return { ...result };
156
174
  }
157
175
 
158
176
  module.exports = {
159
177
  getClientIp,
160
178
  parseUserAgent,
161
179
  };
180
+
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const zlib = require('zlib');
8
- const { supportsBrotli } = require('./negotiate');
8
+ const negotiate = require('./negotiate');
9
9
 
10
10
  /**
11
11
  * Create a zlib compression transform stream based on encoding
@@ -23,7 +23,7 @@ function createCompressionStream(encoding, options = {}) {
23
23
 
24
24
  switch (enc) {
25
25
  case 'br': {
26
- if (!supportsBrotli()) {
26
+ if (options.supportsBrotli === false || !negotiate.supportsBrotli()) {
27
27
  return null;
28
28
  }
29
29
  const brotliOpts = { ...(options.brotli || {}) };
@@ -55,6 +55,7 @@ function createCompressionMiddleware(options = {}) {
55
55
  const customFilter = typeof options.filter === 'function' ? options.filter : null;
56
56
 
57
57
  const comp = compression({
58
+ ...options,
58
59
  threshold,
59
60
  brotli: options.brotli,
60
61
  level: options.level,
@@ -73,7 +74,6 @@ function createCompressionMiddleware(options = {}) {
73
74
  }
74
75
  return compression.filter(req, res);
75
76
  },
76
- ...options,
77
77
  });
78
78
 
79
79
  return function compressionMiddleware(req, res, next) {
@@ -69,6 +69,9 @@ function parseAcceptEncoding(header) {
69
69
  return results;
70
70
  }
71
71
 
72
+ const DEFAULT_SUPPORTED_ENCODINGS = Object.freeze(getDefaultSupportedEncodings());
73
+ const DEFAULT_SUPPORTED_STRING = DEFAULT_SUPPORTED_ENCODINGS.join(',');
74
+
72
75
  const selectEncodingCache = new Map();
73
76
  const MAX_ENCODING_CACHE_SIZE = 128;
74
77
 
@@ -83,16 +86,20 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
83
86
  return 'identity';
84
87
  }
85
88
 
86
- const supported = Array.isArray(supportedEncodings) && supportedEncodings.length > 0
87
- ? supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli())
88
- : getDefaultSupportedEncodings();
89
+ const isDefault = !supportedEncodings || !Array.isArray(supportedEncodings) || supportedEncodings.length === 0;
90
+ const cacheKey = isDefault
91
+ ? `${acceptEncoding}|${DEFAULT_SUPPORTED_STRING}`
92
+ : `${acceptEncoding}|${supportedEncodings.join(',')}`;
89
93
 
90
- const cacheKey = `${acceptEncoding}|${supported.join(',')}`;
91
94
  const cached = selectEncodingCache.get(cacheKey);
92
95
  if (cached !== undefined) {
93
96
  return cached;
94
97
  }
95
98
 
99
+ const supported = isDefault
100
+ ? DEFAULT_SUPPORTED_ENCODINGS
101
+ : supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli());
102
+
96
103
  const clientPreferences = parseAcceptEncoding(acceptEncoding);
97
104
  if (clientPreferences.length === 0) {
98
105
  if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
@@ -12,10 +12,14 @@ const EventEmitter = require('events');
12
12
  * @param {string} pattern
13
13
  * @returns {RegExp}
14
14
  */
15
+ const patternRegexCache = new Map();
16
+
15
17
  function patternToRegExp(pattern) {
16
18
  if (pattern === '*' || pattern === '**') {
17
19
  return /^.*$/;
18
20
  }
21
+ const cached = patternRegexCache.get(pattern);
22
+ if (cached !== undefined) return cached;
19
23
 
20
24
  const escaped = pattern
21
25
  .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
@@ -23,7 +27,11 @@ function patternToRegExp(pattern) {
23
27
  .replace(/\*/g, '[^.]+')
24
28
  .replace(/___DOUBLE_STAR___/g, '.*');
25
29
 
26
- return new RegExp(`^${escaped}$`);
30
+ const reg = new RegExp(`^${escaped}$`);
31
+ if (patternRegexCache.size < 500) {
32
+ patternRegexCache.set(pattern, reg);
33
+ }
34
+ return reg;
27
35
  }
28
36
 
29
37
  /**
@@ -193,8 +201,9 @@ class EventDispatcher extends EventEmitter {
193
201
  // 1. Exact listeners
194
202
  if (this.exactListeners.has(event)) {
195
203
  const list = this.exactListeners.get(event);
196
- const toKeep = [];
197
- for (const item of list) {
204
+ let hasOnce = false;
205
+ for (let i = 0; i < list.length; i++) {
206
+ const item = list[i];
198
207
  if (matchesSender(item.sender, sender)) {
199
208
  matched.push({
200
209
  priority: item.priority,
@@ -207,53 +216,67 @@ class EventDispatcher extends EventEmitter {
207
216
  once: item.once,
208
217
  });
209
218
  }
210
- if (!item.once) {
211
- toKeep.push(item);
219
+ if (item.once) {
220
+ hasOnce = true;
212
221
  }
213
222
  }
214
- this.exactListeners.set(event, toKeep);
223
+ if (hasOnce) {
224
+ this.exactListeners.set(event, list.filter((l) => !l.once));
225
+ }
215
226
  }
216
227
 
217
228
  // 2. Wildcard listeners
218
- const remainingWildcards = [];
219
- for (const item of this.wildcardListeners) {
220
- if (item.regex.test(event) && matchesSender(item.sender, sender)) {
229
+ if (this.wildcardListeners.length > 0) {
230
+ let hasOnce = false;
231
+ for (let i = 0; i < this.wildcardListeners.length; i++) {
232
+ const item = this.wildcardListeners[i];
233
+ if (item.regex.test(event) && matchesSender(item.sender, sender)) {
234
+ matched.push({
235
+ priority: item.priority,
236
+ handler: item.handler,
237
+ isAny: false,
238
+ remove: () => {
239
+ this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
240
+ },
241
+ once: item.once,
242
+ });
243
+ if (item.once) {
244
+ hasOnce = true;
245
+ }
246
+ }
247
+ }
248
+ if (hasOnce) {
249
+ this.wildcardListeners = this.wildcardListeners.filter((l) => !l.once);
250
+ }
251
+ }
252
+
253
+ // 3. Any listeners
254
+ if (this.anyListeners.length > 0) {
255
+ let hasOnce = false;
256
+ for (let i = 0; i < this.anyListeners.length; i++) {
257
+ const item = this.anyListeners[i];
221
258
  matched.push({
222
259
  priority: item.priority,
223
260
  handler: item.handler,
224
- isAny: false,
261
+ isAny: true,
225
262
  remove: () => {
226
- this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
263
+ this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
227
264
  },
228
265
  once: item.once,
229
266
  });
267
+ if (item.once) {
268
+ hasOnce = true;
269
+ }
230
270
  }
231
- if (!item.once) {
232
- remainingWildcards.push(item);
233
- }
234
- }
235
- this.wildcardListeners = remainingWildcards;
236
-
237
- // 3. Any listeners
238
- const remainingAny = [];
239
- for (const item of this.anyListeners) {
240
- matched.push({
241
- priority: item.priority,
242
- handler: item.handler,
243
- isAny: true,
244
- remove: () => {
245
- this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
246
- },
247
- once: item.once,
248
- });
249
- if (!item.once) {
250
- remainingAny.push(item);
271
+ if (hasOnce) {
272
+ this.anyListeners = this.anyListeners.filter((l) => !l.once);
251
273
  }
252
274
  }
253
- this.anyListeners = remainingAny;
254
275
 
255
276
  // Sort by priority descending (higher numbers first)
256
- matched.sort((a, b) => b.priority - a.priority);
277
+ if (matched.length > 1) {
278
+ matched.sort((a, b) => b.priority - a.priority);
279
+ }
257
280
 
258
281
  return matched;
259
282
  }
@@ -2,7 +2,7 @@
2
2
  * ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
3
3
  */
4
4
 
5
- const { getAllModels } = require('../orm/model');
5
+ const modelModule = require('../orm/model');
6
6
 
7
7
  /**
8
8
  * Generate OpenAPI 3 components.schemas from registered ORM models
@@ -10,8 +10,7 @@ const { getAllModels } = require('../orm/model');
10
10
  * @returns {Record<string, object>}
11
11
  */
12
12
  function generateOrmOpenApiSchemas(options = {}) {
13
- const { exclude = [] } = options;
14
- const models = getAllModels();
13
+ const { exclude = [], models = modelModule.getAllModels() } = options;
15
14
  const schemas = {};
16
15
 
17
16
  for (const [name, model] of models) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const { ModelEvents, Hooks } = require('../events');
8
- const { getModel } = require('../model');
8
+ const modelModule = require('../model');
9
9
 
10
10
  /** @type {Set<import('./layer').OrmCacheLayer>} */
11
11
  const layers = new Set();
@@ -27,7 +27,7 @@ function registerOrmCacheListeners(cacheLayer) {
27
27
  */
28
28
  function handleMutation(record, ctx, op) {
29
29
  const modelName = ctx.model;
30
- const model = getModel(modelName);
30
+ const model = modelModule.getModel(modelName);
31
31
  if (!model) return;
32
32
 
33
33
  for (const layer of layers) {
@@ -68,7 +68,17 @@ function unregisterOrmCacheListeners(cacheLayer) {
68
68
  }
69
69
  }
70
70
 
71
+ function _resetForTesting() {
72
+ for (const unsub of unsubs) {
73
+ if (typeof unsub === 'function') unsub();
74
+ }
75
+ unsubs = [];
76
+ layers.clear();
77
+ hooked = false;
78
+ }
79
+
71
80
  module.exports = {
72
81
  registerOrmCacheListeners,
73
82
  unregisterOrmCacheListeners,
83
+ _resetForTesting,
74
84
  };
@@ -9,14 +9,20 @@
9
9
  * @returns {Set<string>} Set of JSON column names
10
10
  */
11
11
  function getJsonColumns(model) {
12
+ if (model && model._jsonColumns) {
13
+ return model._jsonColumns;
14
+ }
12
15
  const jsonCols = new Set();
13
- if (model.columns) {
16
+ if (model && model.columns) {
14
17
  for (const [name, meta] of model.columns) {
15
18
  if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
16
19
  jsonCols.add(name);
17
20
  }
18
21
  }
19
22
  }
23
+ if (model) {
24
+ model._jsonColumns = jsonCols;
25
+ }
20
26
  return jsonCols;
21
27
  }
22
28
 
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