webspresso 0.0.103 → 0.0.105

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 (165) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +4 -3
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/orm-map-html.js +2 -8
  19. package/bin/utils/project.js +2 -2
  20. package/core/auth/hash.js +5 -4
  21. package/core/auth/jwt.js +6 -57
  22. package/core/auth/magic-link.js +4 -5
  23. package/core/auth/manager.js +3 -5
  24. package/core/auth/oauth.js +45 -30
  25. package/core/auth/totp.js +5 -16
  26. package/core/auth/user-agent.js +1 -21
  27. package/core/cache/byte-size.js +123 -0
  28. package/core/cache/index.js +21 -0
  29. package/core/cache/lru.js +477 -0
  30. package/core/cache/tagged-lru.js +346 -0
  31. package/core/commands/registry.js +6 -20
  32. package/core/content/renderer.js +1 -13
  33. package/core/early-hints/index.js +267 -0
  34. package/core/errors/domain.js +48 -0
  35. package/core/errors/http.js +31 -0
  36. package/core/errors/index.js +10 -0
  37. package/core/errors/middleware.js +2 -8
  38. package/core/errors/normalize.js +88 -2
  39. package/core/events/discovery.js +6 -24
  40. package/core/events/dispatcher.js +2 -7
  41. package/core/forms/form-builder.js +1 -9
  42. package/core/health/index.js +41 -0
  43. package/core/health/middleware.js +213 -0
  44. package/core/health/normalize.js +140 -0
  45. package/core/health/probes.js +203 -0
  46. package/core/limits/index.js +34 -0
  47. package/core/limits/middleware.js +317 -0
  48. package/core/limits/normalize.js +222 -0
  49. package/core/orm/batch.js +70 -0
  50. package/core/orm/cache/fingerprint.js +2 -2
  51. package/core/orm/cache/memory-provider.js +27 -110
  52. package/core/orm/index.js +76 -8
  53. package/core/orm/json-fields.js +22 -8
  54. package/core/orm/model.js +10 -0
  55. package/core/orm/query-builder.js +63 -9
  56. package/core/orm/query-filters.js +393 -0
  57. package/core/orm/repository.js +318 -19
  58. package/core/orm/schema-helpers.js +8 -10
  59. package/core/orm/scopes.js +321 -1
  60. package/core/orm/transaction.js +10 -2
  61. package/core/orm/utils.js +4 -86
  62. package/core/queue/adapters/database.js +4 -8
  63. package/core/queue/manager.js +55 -41
  64. package/core/resilience/circuit-breaker.js +391 -0
  65. package/core/resilience/errors.js +63 -0
  66. package/core/resilience/index.js +53 -0
  67. package/core/resilience/registry.js +97 -0
  68. package/core/resilience/retry.js +203 -0
  69. package/core/schema-cache.js +9 -2
  70. package/core/shutdown/http-adapter.js +4 -1
  71. package/core/shutdown/shutdown-manager.js +132 -31
  72. package/core/utils/array.js +12 -0
  73. package/core/utils/base64url.js +8 -0
  74. package/core/utils/crypto.js +191 -0
  75. package/core/utils/ensure-leading-slash.js +6 -0
  76. package/core/utils/escape-html.js +7 -0
  77. package/core/utils/escape-regexp.js +7 -0
  78. package/core/utils/fetch.js +121 -0
  79. package/core/utils/fs.js +144 -0
  80. package/core/utils/get-client-ip.js +6 -0
  81. package/core/utils/hmac.js +6 -0
  82. package/core/utils/html.js +72 -0
  83. package/core/utils/ip.js +86 -0
  84. package/core/utils/json.js +98 -0
  85. package/core/utils/object.js +106 -0
  86. package/core/utils/random.js +9 -0
  87. package/core/utils/regex.js +77 -0
  88. package/core/utils/safe-compare.js +3 -0
  89. package/core/utils/sha256.js +6 -0
  90. package/core/utils/string.js +87 -0
  91. package/core/utils/strip-trailing-slash.js +6 -0
  92. package/core/utils/time.js +96 -0
  93. package/core/utils/timeout.js +348 -0
  94. package/core/utils/url.js +106 -0
  95. package/core/utils/walk.js +7 -0
  96. package/index.d.ts +627 -2
  97. package/index.js +158 -0
  98. package/package.json +1 -2
  99. package/plugins/admin-panel/auth.js +15 -7
  100. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  101. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  102. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  103. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  104. package/plugins/admin-panel/core/api-extensions.js +3 -80
  105. package/plugins/admin-panel/index.js +1 -1
  106. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  107. package/plugins/admin-panel/modules/menu.js +1 -1
  108. package/plugins/api-key/crypto.js +3 -26
  109. package/plugins/api-key/scopes.js +5 -7
  110. package/plugins/audit-log/api-handlers.js +8 -7
  111. package/plugins/audit-log/middleware.js +11 -2
  112. package/plugins/audit-log/parse.js +3 -3
  113. package/plugins/basic-auth/index.js +2 -26
  114. package/plugins/cors.js +2 -2
  115. package/plugins/csrf/index.js +7 -23
  116. package/plugins/dashboard/app.js +2 -2
  117. package/plugins/dashboard/index.js +5 -5
  118. package/plugins/data-exchange/record-selection.js +2 -5
  119. package/plugins/email/api-handlers.js +5 -18
  120. package/plugins/email/auth-bridge.js +3 -2
  121. package/plugins/file-manager/admin-component.js +3 -1
  122. package/plugins/file-manager/core.js +34 -4
  123. package/plugins/health-check.js +2 -1
  124. package/plugins/index.js +2 -0
  125. package/plugins/lazy.js +5 -6
  126. package/plugins/mcp/auth.js +1 -13
  127. package/plugins/mcp/discovery.js +18 -17
  128. package/plugins/mcp/index.js +23 -1
  129. package/plugins/mcp/transports/sse.js +10 -1
  130. package/plugins/orm-cache-admin/admin-component.js +3 -2
  131. package/plugins/polar/src/api.js +11 -3
  132. package/plugins/polar/src/webhooks.js +7 -13
  133. package/plugins/rate-limit/index.js +3 -1
  134. package/plugins/recaptcha/helpers.js +1 -7
  135. package/plugins/recaptcha/verify.js +37 -23
  136. package/plugins/redirect/index.js +57 -7
  137. package/plugins/rest-resources/index.js +1 -207
  138. package/plugins/rls/index.js +170 -0
  139. package/plugins/schema-explorer/admin-component.js +4 -2
  140. package/plugins/schema-explorer.js +40 -29
  141. package/plugins/seo-checker/index.js +5 -2
  142. package/plugins/site-analytics/tracking.js +21 -18
  143. package/plugins/sitemap.js +21 -17
  144. package/plugins/swagger.js +13 -17
  145. package/plugins/upload/index.js +4 -3
  146. package/plugins/upload/orphan-pruner.js +4 -3
  147. package/plugins/webhooks/crypto.js +6 -28
  148. package/plugins/webhooks/dispatcher.js +14 -9
  149. package/plugins/webhooks/index.js +6 -0
  150. package/plugins/webhooks/matcher.js +28 -17
  151. package/plugins/webhooks/sender.js +40 -13
  152. package/src/app-context.js +41 -5
  153. package/src/dev/banner.js +6 -2
  154. package/src/dev-reload/index.js +47 -6
  155. package/src/file-router.js +1 -3
  156. package/src/helpers.js +180 -33
  157. package/src/modules/module-discovery.js +1 -9
  158. package/src/pages/page-loader.js +53 -1
  159. package/src/plugin-manager.js +90 -17
  160. package/src/server.js +137 -26
  161. package/src/services/discovery.js +19 -40
  162. package/src/services/executor.js +86 -25
  163. package/src/services/memoize.js +3 -25
  164. package/src/services/registry.js +6 -0
  165. package/src/template/precompile.js +8 -19
@@ -383,12 +383,56 @@ class QueryBuilder {
383
383
  return this;
384
384
  }
385
385
 
386
+ /**
387
+ * Set authenticated user context for RLS
388
+ * @param {Object|null} user - User object
389
+ * @returns {this}
390
+ */
391
+ forUser(user) {
392
+ this.scopeContext.user = user;
393
+ return this;
394
+ }
395
+
396
+ /**
397
+ * Bypass Row Level Security on this query
398
+ * @returns {this}
399
+ */
400
+ withoutRls() {
401
+ this.scopeContext.rlsDisabled = true;
402
+ return this;
403
+ }
404
+
405
+ /**
406
+ * Apply custom scope context
407
+ * @param {Object} customContext
408
+ * @returns {this}
409
+ */
410
+ withContext(customContext) {
411
+ Object.assign(this.scopeContext, customContext);
412
+ return this;
413
+ }
414
+
415
+ /**
416
+ * Explicitly set action mode for RLS
417
+ * @param {'read'|'update'|'delete'|'create'} action
418
+ * @returns {this}
419
+ */
420
+ asAction(action) {
421
+ this.action = action;
422
+ return this;
423
+ }
424
+
386
425
  /**
387
426
  * Resolve active Knex instance (explicit or ambient transaction)
388
427
  * @returns {import('knex').Knex|import('knex').Knex.Transaction}
389
428
  */
390
429
  getActiveKnex() {
391
430
  if (this.knex && (this.knex.isTransaction || typeof this.knex.commit === 'function')) {
431
+ if (typeof this.knex.isCompleted === 'function' && this.knex.isCompleted()) {
432
+ const ambientTrx = getAmbientTransaction();
433
+ if (ambientTrx) return ambientTrx;
434
+ return this.knex;
435
+ }
392
436
  return this.knex;
393
437
  }
394
438
  const ambientTrx = getAmbientTransaction();
@@ -404,7 +448,14 @@ class QueryBuilder {
404
448
  */
405
449
  _hookTrx() {
406
450
  const active = this.getActiveKnex();
407
- return (active && (active.isTransaction || typeof active.commit === 'function')) ? active : null;
451
+ if (!active) return null;
452
+ if (active.isTransaction || typeof active.commit === 'function') {
453
+ if (typeof active.isCompleted === 'function' && active.isCompleted()) {
454
+ return null;
455
+ }
456
+ return active;
457
+ }
458
+ return null;
408
459
  }
409
460
 
410
461
  /**
@@ -414,12 +465,12 @@ class QueryBuilder {
414
465
  * @returns {import('knex').Knex.QueryBuilder}
415
466
  */
416
467
  toKnex(options = {}) {
417
- const { includeLimitOffset = true } = options;
468
+ const { includeLimitOffset = true, action = this.action || 'read', mutationData = null } = options;
418
469
  const activeKnex = this.getActiveKnex();
419
470
  let qb = activeKnex(this.model.table);
420
471
 
421
- // Apply global scopes
422
- qb = applyScopes(qb, this.scopeContext, this.model);
472
+ // Apply global scopes (soft delete, multi-tenant, RLS)
473
+ qb = applyScopes(qb, this.scopeContext, this.model, action, mutationData);
423
474
 
424
475
  // Apply selects
425
476
  if (this.state.selects.length > 0) {
@@ -795,7 +846,7 @@ class QueryBuilder {
795
846
  * @returns {Promise<number>} Number of deleted records
796
847
  */
797
848
  async delete() {
798
- const n = await this.toKnex().delete();
849
+ const n = await this.toKnex({ action: 'delete' }).delete();
799
850
  if (this.cacheLayer && n > 0) {
800
851
  this.cacheLayer.invalidateModelAll(this.model);
801
852
  }
@@ -808,8 +859,10 @@ class QueryBuilder {
808
859
  * @returns {Promise<number>} Number of updated records
809
860
  */
810
861
  async update(data) {
862
+ const { checkUpdateRls } = require('./scopes');
863
+ checkUpdateRls(data, this.scopeContext, this.model);
811
864
  const serialized = serializeJsonFields(data, this.jsonColumns);
812
- const n = await this.toKnex().update(serialized);
865
+ const n = await this.toKnex({ action: 'update', mutationData: data }).update(serialized);
813
866
  if (this.cacheLayer && n > 0) {
814
867
  this.cacheLayer.invalidateModelAll(this.model);
815
868
  }
@@ -839,14 +892,15 @@ class QueryBuilder {
839
892
  clone() {
840
893
  const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext }, this.cacheLayer);
841
894
  cloned.jsonColumns = this.jsonColumns;
895
+ cloned.action = this.action;
842
896
  cloned.state = {
843
- wheres: [...this.state.wheres],
844
- orderBys: [...this.state.orderBys],
897
+ wheres: this.state.wheres.map(w => ({ ...w })),
898
+ orderBys: this.state.orderBys.map(o => ({ ...o })),
845
899
  selects: [...this.state.selects],
846
900
  withs: [...this.state.withs],
847
901
  limitValue: this.state.limitValue,
848
902
  offsetValue: this.state.offsetValue,
849
- scopeContext: { ...this.state.scopeContext },
903
+ scopeContext: cloned.scopeContext,
850
904
  };
851
905
  return cloned;
852
906
  }
@@ -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
+ };