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/.agents/ORM.md CHANGED
@@ -51,7 +51,9 @@ import { defineModel, zdb } from 'webspresso';
51
51
  export default defineModel({
52
52
  name: 'Product',
53
53
  table: 'products',
54
- schema: zdb.schema({
54
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
55
+ softDelete: true, // auto-injects deletedAt & manages soft-delete scope
56
+ schema: {
55
57
  id: zdb.id(),
56
58
  name: zdb.string().min(2),
57
59
  slug: zdb.string().unique(),
@@ -61,9 +63,7 @@ export default defineModel({
61
63
  metadata: zdb.json({ optional: true }),
62
64
  category_id: zdb.integer(),
63
65
  active: zdb.boolean({ default: true }),
64
- created_at: zdb.timestamp({ auto: 'create' }),
65
- updated_at: zdb.timestamp({ auto: 'update' }),
66
- }),
66
+ },
67
67
  relations: {
68
68
  category: { type: 'belongsTo', model: 'Category', foreignKey: 'category_id' },
69
69
  reviews: { type: 'hasMany', model: 'Review', foreignKey: 'product_id' },
@@ -81,6 +81,13 @@ export default defineModel({
81
81
  });
82
82
  ```
83
83
 
84
+ ### Automatic Timestamps & Soft Deletes (`timestamps`, `softDelete`)
85
+ Instead of manually defining boilerplate timestamp and soft delete columns in `schema`, configure them declaratively:
86
+ - **Top-level / Options:** `timestamps: true` (or `options: { timestamps: true }`) automatically injects `createdAt` and `updatedAt` into the Zod schema, validation rules, column map, and Knex migrations.
87
+ - **Soft Deletes:** `softDelete: true` (or `softDeletes: true`) automatically injects `deletedAt` (nullable/optional) into the schema and activates the repository `whereNull('deletedAt')` soft-delete scope.
88
+ - **Custom column names & snake_case:** `timestamps: { snakeCase: true }` or `timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }`, `softDelete: { deletedAt: 'deleted_at' }`.
89
+ - **Explicit Schema Spread:** `...zdb.timestamps()` and `...zdb.softDeletes()` are also supported for explicit schema definitions.
90
+
84
91
  ### Supported `zdb` Data Types
85
92
  - `zdb.id()` — Primary key (BigInt / Auto-increment)
86
93
  - `zdb.string({ max, unique, optional, sortable })` — String / VarChar
@@ -90,6 +97,8 @@ export default defineModel({
90
97
  - `zdb.file()` — File asset URL / upload path (non-sortable by default)
91
98
  - `zdb.json()` — Serialized JSON column (non-sortable by default)
92
99
  - `zdb.timestamp({ auto, sortable })` — Timestamp (`auto: 'create'` / `auto: 'update'`)
100
+ - `zdb.timestamps({ snakeCase })` — Timestamp pair (`createdAt`, `updatedAt`)
101
+ - `zdb.softDelete()`, `zdb.softDeletes({ snakeCase })` — Soft delete timestamp (`deletedAt`)
93
102
 
94
103
  *Note: All `zdb` types support chainable `.config({ sortable: boolean })` or options object `{ sortable: boolean }`.*
95
104
 
@@ -21,8 +21,10 @@ module.exports = defineModel({
21
21
  name: 'Product',
22
22
  table: 'products',
23
23
  primaryKey: 'id',
24
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
25
+ softDelete: true, // auto-injects deletedAt & enables soft-delete scope
24
26
 
25
- schema: zdb.object({
27
+ schema: {
26
28
  id: zdb.id(),
27
29
  name: zdb.string().min(1).max(255),
28
30
  slug: zdb.string().unique(),
@@ -30,10 +32,7 @@ module.exports = defineModel({
30
32
  in_stock: zdb.boolean().default(true),
31
33
  metadata: zdb.json().optional(),
32
34
  user_id: zdb.foreignKey('users.id'),
33
- created_at: zdb.timestamp().autoCreate(),
34
- updated_at: zdb.timestamp().autoUpdate(),
35
- deleted_at: zdb.timestamp().nullable(),
36
- }),
35
+ },
37
36
 
38
37
  relations: {
39
38
  user: {
package/README.md CHANGED
@@ -115,13 +115,13 @@ const { defineModel, zdb } = require('webspresso');
115
115
  module.exports = defineModel({
116
116
  name: 'Note',
117
117
  table: 'notes',
118
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
119
+ softDelete: true, // auto-injects deletedAt & enables soft-delete scope
118
120
  schema: {
119
121
  id: zdb.id(),
120
122
  title: zdb.string(200),
121
123
  content: zdb.text(),
122
124
  userId: zdb.integer(),
123
- ...zdb.timestamps(),
124
- ...zdb.softDeletes(),
125
125
  },
126
126
  relations: {
127
127
  user: { type: 'belongsTo', model: 'User', foreignKey: 'userId' },
package/core/auth/jwt.js CHANGED
@@ -6,18 +6,17 @@
6
6
 
7
7
  const crypto = require('crypto');
8
8
 
9
+ const HS256_HEADER_BASE64URL = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
10
+
9
11
  /**
10
12
  * Base64URL encode string or Buffer
11
13
  * @param {string|Buffer} input
12
14
  * @returns {string}
13
15
  */
14
16
  function base64UrlEncode(input) {
15
- const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
16
- return buf
17
- .toString('base64')
18
- .replace(/=/g, '')
19
- .replace(/\+/g, '-')
20
- .replace(/\//g, '_');
17
+ return Buffer.isBuffer(input)
18
+ ? input.toString('base64url')
19
+ : Buffer.from(String(input), 'utf8').toString('base64url');
21
20
  }
22
21
 
23
22
  /**
@@ -26,11 +25,7 @@ function base64UrlEncode(input) {
26
25
  * @returns {string}
27
26
  */
28
27
  function base64UrlDecode(input) {
29
- let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
30
- while (base64.length % 4 !== 0) {
31
- base64 += '=';
32
- }
33
- return Buffer.from(base64, 'base64').toString('utf8');
28
+ return Buffer.from(String(input), 'base64url').toString('utf8');
34
29
  }
35
30
 
36
31
  /**
@@ -110,16 +105,14 @@ function signJwt(payload, secret, options = {}) {
110
105
  ...payload,
111
106
  };
112
107
 
113
- const encodedHeader = base64UrlEncode(JSON.stringify(header));
108
+ const encodedHeader = algorithm === 'HS256' ? HS256_HEADER_BASE64URL : base64UrlEncode(JSON.stringify(header));
114
109
  const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
115
110
 
116
111
  const signingInput = `${encodedHeader}.${encodedPayload}`;
117
- const signature = crypto
112
+ const encodedSignature = crypto
118
113
  .createHmac('sha256', secret)
119
114
  .update(signingInput)
120
- .digest();
121
-
122
- const encodedSignature = base64UrlEncode(signature);
115
+ .digest('base64url');
123
116
 
124
117
  return `${signingInput}.${encodedSignature}`;
125
118
  }
@@ -177,7 +170,7 @@ function verifyJwt(token, secret, options = {}) {
177
170
  .update(signingInput)
178
171
  .digest();
179
172
 
180
- const actualSignatureBuf = Buffer.from(encodedSignature.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
173
+ const actualSignatureBuf = Buffer.from(encodedSignature, 'base64url');
181
174
 
182
175
  if (
183
176
  expectedSignatureBuf.length !== actualSignatureBuf.length ||
@@ -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