velocious 1.0.466 → 1.0.467

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 (29) hide show
  1. package/README.md +2 -1
  2. package/build/configuration.js +2 -2
  3. package/build/database/pool/async-tracked-multi-connection.js +7 -2
  4. package/build/database/pool/single-multi-use.js +38 -0
  5. package/build/frontend-model-controller.js +162 -56
  6. package/build/frontend-models/query.js +55 -33
  7. package/build/src/configuration.js +3 -3
  8. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  9. package/build/src/database/pool/async-tracked-multi-connection.js +9 -3
  10. package/build/src/database/pool/single-multi-use.d.ts +1 -0
  11. package/build/src/database/pool/single-multi-use.d.ts.map +1 -1
  12. package/build/src/database/pool/single-multi-use.js +34 -1
  13. package/build/src/frontend-model-controller.d.ts +45 -5
  14. package/build/src/frontend-model-controller.d.ts.map +1 -1
  15. package/build/src/frontend-model-controller.js +155 -57
  16. package/build/src/frontend-models/query.d.ts +8 -0
  17. package/build/src/frontend-models/query.d.ts.map +1 -1
  18. package/build/src/frontend-models/query.js +53 -34
  19. package/build/src/utils/ransack.d.ts +11 -3
  20. package/build/src/utils/ransack.d.ts.map +1 -1
  21. package/build/src/utils/ransack.js +42 -23
  22. package/build/utils/ransack.js +44 -22
  23. package/package.json +1 -1
  24. package/src/configuration.js +2 -2
  25. package/src/database/pool/async-tracked-multi-connection.js +7 -2
  26. package/src/database/pool/single-multi-use.js +38 -0
  27. package/src/frontend-model-controller.js +162 -56
  28. package/src/frontend-models/query.js +55 -33
  29. package/src/utils/ransack.js +44 -22
package/README.md CHANGED
@@ -578,6 +578,7 @@ Use `await FrontendModelBase.waitForIdle()` when a test harness or app lifecycle
578
578
  Frontend-model HTTP requests always use `credentials: "include"` so shared custom commands can set session cookies without app-level transport overrides.
579
579
 
580
580
  Unexpected frontend-model endpoint failures stay client-safe in production with `errorMessage: "Request failed."`.
581
+ Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors.
581
582
  In `development` and `test`, Velocious also includes `debugErrorClass`, `debugErrorMessage`, and `debugBacktrace` fields so browser/system-test failures are easier to diagnose without exposing those details in production.
582
583
  Other non-production environments, such as `staging`, keep the same client-safe default unless you explicitly opt in with `exposeInternalErrorsToClients: true`:
583
584
 
@@ -1619,7 +1620,7 @@ configuration.getErrorEvents().on("all-error", ({error, errorType}) => {
1619
1620
  })
1620
1621
  ```
1621
1622
 
1622
- Genuinely unexpected frontend-model command failures reach this bus too. The frontend-model controller catches them to return a client-safe `Request failed.` response, but it also emits them as `framework-error`/`all-error` (with `context.frontendModelEndpoint === true`) so they are reported instead of being silently swallowed. Expected user-flow errors are excluded: validation failures are forwarded with their real message (for example `Name can't be blank`) rather than the generic one, and `error.velocious`-annotated / `safeToExpose` / `errorType`-marked errors keep their expected-error status — none of these reach the error bus.
1623
+ Genuinely unexpected frontend-model command failures reach this bus too. The frontend-model controller catches them to return a client-safe `Request failed.` response, but it also emits them as `framework-error`/`all-error` (with `context.frontendModelEndpoint === true`) so they are reported instead of being silently swallowed. Expected user-flow errors are excluded: validation failures are forwarded with their real message (for example `Name can't be blank`), invalid client query descriptors are returned as frontend-model query errors, and `error.velocious`-annotated / `safeToExpose` / `errorType`-marked errors keep their expected-error status — none of these reach the error bus.
1623
1624
 
1624
1625
  ## Use the Websocket client API (HTTP-like)
1625
1626
 
@@ -2230,8 +2230,8 @@ export default class VelociousConfiguration {
2230
2230
 
2231
2231
  if (!matches) continue
2232
2232
 
2233
- this.withoutCurrentConnectionContexts(() => {
2234
- void Promise
2233
+ void this.withoutCurrentConnectionContexts(() => {
2234
+ return Promise
2235
2235
  .resolve()
2236
2236
  .then(() => this._deliverWebsocketChannelBroadcast(subscription, body, {eventId: meta?.eventId}))
2237
2237
  .catch((error) => {
@@ -6,6 +6,7 @@ import BasePool, {POOL_CONFIGURATION_KEY} from "./base.js"
6
6
  export const CLOSED_CONNECTION = Symbol("velociousClosedConnection")
7
7
  const IDLE_CONNECTION_CHECKED_IN_AT = Symbol("velociousIdleConnectionCheckedInAt")
8
8
  const CONNECTION_CHECKED_OUT_AT = Symbol("velociousConnectionCheckedOutAt")
9
+ const SUPPRESSED_CONNECTION_CONTEXT = Symbol("velociousSuppressedConnectionContext")
9
10
  const DEFAULT_MAX_CONNECTIONS = 10
10
11
  const DEFAULT_IDLE_TIMEOUT_MILLIS = 5000
11
12
  const DEFAULT_CHECKOUT_TIMEOUT_MILLIS = 10000
@@ -103,7 +104,7 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
103
104
  * Runs a callback without the inherited current connection context.
104
105
  * @type {(callback: () => ?) => ?}
105
106
  */
106
- const withoutCurrentConnectionContext = (callback) => this.asyncLocalStorage.run(undefined, callback)
107
+ const withoutCurrentConnectionContext = (callback) => this.asyncLocalStorage.run(SUPPRESSED_CONNECTION_CONTEXT, callback)
107
108
  this._withoutCurrentConnectionContext = withoutCurrentConnectionContext
108
109
  }
109
110
 
@@ -720,6 +721,7 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
720
721
  const id = this.asyncLocalStorage.getStore()
721
722
 
722
723
  if (id === undefined) return this.currentFallbackConnectionOrFail()
724
+ if (id === SUPPRESSED_CONNECTION_CONTEXT) return this.currentFallbackConnectionOrFail()
723
725
 
724
726
  this.ensureConnectionIsInUse(id)
725
727
 
@@ -815,6 +817,7 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
815
817
  getCurrentContextConnection() {
816
818
  const id = this.asyncLocalStorage.getStore()
817
819
 
820
+ if (id === SUPPRESSED_CONNECTION_CONTEXT) return undefined
818
821
  if (id === undefined) return this._testSharedConnection
819
822
 
820
823
  return this.getCurrentConnection()
@@ -825,7 +828,9 @@ export default class VelociousDatabasePoolAsyncTrackedMultiConnection extends Ba
825
828
  * @returns {boolean} - Whether nested code can reuse the current connection context.
826
829
  */
827
830
  hasCurrentConnectionContext() {
828
- return this.asyncLocalStorage.getStore() !== undefined
831
+ const id = this.asyncLocalStorage.getStore()
832
+
833
+ return id !== undefined && id !== SUPPRESSED_CONNECTION_CONTEXT
829
834
  }
830
835
 
831
836
  /**
@@ -3,6 +3,8 @@
3
3
  import BasePool from "./base.js"
4
4
 
5
5
  export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
6
+ suppressedConnectionContextCount = 0
7
+
6
8
  /**
7
9
  * Runs checkin.
8
10
  * @param {import("../drivers/base.js").default} connection - Connection.
@@ -58,6 +60,32 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
58
60
  }
59
61
  }
60
62
 
63
+ /**
64
+ * Runs without current connection context.
65
+ * @template T
66
+ * @param {() => T} callback - Callback to run without the shared current connection.
67
+ * @returns {T} - Callback result.
68
+ */
69
+ withoutCurrentConnectionContext(callback) {
70
+ this.suppressedConnectionContextCount += 1
71
+
72
+ try {
73
+ const result = callback()
74
+
75
+ if (result instanceof Promise) {
76
+ return /** @type {T} */ (result.finally(() => {
77
+ this.suppressedConnectionContextCount -= 1
78
+ }))
79
+ }
80
+
81
+ this.suppressedConnectionContextCount -= 1
82
+ return result
83
+ } catch (error) {
84
+ this.suppressedConnectionContextCount -= 1
85
+ throw error
86
+ }
87
+ }
88
+
61
89
  /**
62
90
  * Clears schema metadata cached by the reusable connection if it exists.
63
91
  * @returns {void} - No return value.
@@ -97,9 +125,19 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
97
125
  * @returns {import("../drivers/base.js").default | undefined} - The current context connection.
98
126
  */
99
127
  getCurrentContextConnection() {
128
+ if (this.suppressedConnectionContextCount > 0) return undefined
129
+
100
130
  return this.connection
101
131
  }
102
132
 
133
+ /**
134
+ * Returns whether the shared connection is available to the current execution context.
135
+ * @returns {boolean} - Whether nested code can reuse the shared connection.
136
+ */
137
+ hasCurrentConnectionContext() {
138
+ return this.suppressedConnectionContextCount === 0
139
+ }
140
+
103
141
  /**
104
142
  * Runs get debug snapshot.
105
143
  * @returns {import("./base.js").DatabasePoolDebugSnapshot} - Diagnostic snapshot for this pool.
@@ -6,7 +6,7 @@ import FrontendModelBaseResource from "./frontend-model-resource/base-resource.j
6
6
  import Response from "./http-server/client/response.js"
7
7
  import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
8
  import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
9
- import {normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
9
+ import {FrontendModelQueryError, normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJoins, normalizePluck as normalizeQueryPluck, normalizePreload as normalizeQueryPreload, normalizeSearchOperator as normalizeQuerySearchOperator, normalizeSort as normalizeQuerySort} from "./frontend-models/query.js"
10
10
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
11
11
  import RoutesResolver from "./routes/resolver.js"
12
12
  import {ValidationError} from "./database/record/index.js"
@@ -14,6 +14,7 @@ import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
14
14
  import VelociousError from "./velocious-error.js"
15
15
  import isDate from "./utils/is-date.js"
16
16
  import isPlainObject from "./utils/plain-object.js"
17
+ import {RansackQueryError, normalizeRansackGroup, parseRansackSort} from "./utils/ransack.js"
17
18
 
18
19
  /**
19
20
  * Runs normalize frontend model preload.
@@ -37,7 +38,7 @@ function normalizeFrontendModelJoins(joins) {
37
38
  try {
38
39
  return normalizeQueryJoins(joins)
39
40
  } catch (error) {
40
- throw frontendModelValidationErrorForError(error)
41
+ throwFrontendModelQueryErrorForParserError(error)
41
42
  }
42
43
  }
43
44
 
@@ -52,7 +53,7 @@ function normalizeFrontendModelSelect(select, rootModelName = null) {
52
53
 
53
54
  if (typeof select === "string") {
54
55
  if (!rootModelName) {
55
- throw frontendModelValidationError("Invalid select shorthand without root model name")
56
+ throw frontendModelQueryError("Invalid select shorthand without root model name")
56
57
  }
57
58
 
58
59
  return {[rootModelName]: [select]}
@@ -60,12 +61,12 @@ function normalizeFrontendModelSelect(select, rootModelName = null) {
60
61
 
61
62
  if (Array.isArray(select)) {
62
63
  if (!rootModelName) {
63
- throw frontendModelValidationError("Invalid select shorthand without root model name")
64
+ throw frontendModelQueryError("Invalid select shorthand without root model name")
64
65
  }
65
66
 
66
67
  for (const attributeName of select) {
67
68
  if (typeof attributeName !== "string") {
68
- throw frontendModelValidationError(`Invalid select attribute for ${rootModelName}: ${typeof attributeName}`)
69
+ throw frontendModelQueryError(`Invalid select attribute for ${rootModelName}: ${typeof attributeName}`)
69
70
  }
70
71
  }
71
72
 
@@ -73,7 +74,7 @@ function normalizeFrontendModelSelect(select, rootModelName = null) {
73
74
  }
74
75
 
75
76
  if (!isPlainObject(select)) {
76
- throw frontendModelValidationError(`Invalid select type: ${typeof select}`)
77
+ throw frontendModelQueryError(`Invalid select type: ${typeof select}`)
77
78
  }
78
79
 
79
80
  /**
@@ -88,12 +89,12 @@ function normalizeFrontendModelSelect(select, rootModelName = null) {
88
89
  }
89
90
 
90
91
  if (!Array.isArray(selectValue)) {
91
- throw frontendModelValidationError(`Invalid select value for ${modelName}: ${typeof selectValue}`)
92
+ throw frontendModelQueryError(`Invalid select value for ${modelName}: ${typeof selectValue}`)
92
93
  }
93
94
 
94
95
  for (const attributeName of selectValue) {
95
96
  if (typeof attributeName !== "string") {
96
- throw frontendModelValidationError(`Invalid select attribute for ${modelName}: ${typeof attributeName}`)
97
+ throw frontendModelQueryError(`Invalid select attribute for ${modelName}: ${typeof attributeName}`)
97
98
  }
98
99
  }
99
100
 
@@ -175,27 +176,25 @@ function frontendModelQueryMetadata(query) {
175
176
  }
176
177
 
177
178
  /**
178
- * Runs frontend model validation error.
179
- * @param {string} message - Validation error message.
180
- * @returns {VelociousError} - Client-safe validation error.
179
+ * Builds a client-safe frontend-model query error.
180
+ * @param {string} message - Error message.
181
+ * @returns {VelociousError} Client-safe query error.
181
182
  */
182
- function frontendModelValidationError(message) {
183
- return VelociousError.safe(message, {code: "frontend-model-validation"})
183
+ function frontendModelQueryError(message) {
184
+ return VelociousError.safe(message, {code: "frontend-model-query-error"})
184
185
  }
185
186
 
186
187
  /**
187
- * Runs frontend model validation error for error.
188
+ * Throws a client-safe frontend-model query error for typed query parser errors.
188
189
  * @param {?} error - Error raised while normalizing client query params.
189
- * @returns {VelociousError} - Client-safe validation error preserving the normalizer message.
190
+ * @returns {never} Always throws.
190
191
  */
191
- function frontendModelValidationErrorForError(error) {
192
- if (error instanceof VelociousError && error.safeToExpose) return error
193
-
194
- const message = error instanceof Error
195
- ? error.message
196
- : String(error)
192
+ function throwFrontendModelQueryErrorForParserError(error) {
193
+ if (error instanceof FrontendModelQueryError || error instanceof RansackQueryError) {
194
+ throw frontendModelQueryError(error.message)
195
+ }
197
196
 
198
- return frontendModelValidationError(message)
197
+ throw error
199
198
  }
200
199
 
201
200
  /**
@@ -338,7 +337,7 @@ function normalizeFrontendModelSearches(searches) {
338
337
  if (!searches) return []
339
338
 
340
339
  if (!Array.isArray(searches)) {
341
- throw frontendModelValidationError(`Invalid searches type: ${typeof searches}`)
340
+ throw frontendModelQueryError(`Invalid searches type: ${typeof searches}`)
342
341
  }
343
342
 
344
343
  /**
@@ -348,7 +347,7 @@ function normalizeFrontendModelSearches(searches) {
348
347
 
349
348
  for (const search of searches) {
350
349
  if (!isPlainObject(search)) {
351
- throw frontendModelValidationError(`Invalid search entry type: ${typeof search}`)
350
+ throw frontendModelQueryError(`Invalid search entry type: ${typeof search}`)
352
351
  }
353
352
 
354
353
  const path = search.path
@@ -356,26 +355,34 @@ function normalizeFrontendModelSearches(searches) {
356
355
  const operator = search.operator
357
356
 
358
357
  if (!Array.isArray(path)) {
359
- throw frontendModelValidationError("Invalid search path: expected an array")
358
+ throw frontendModelQueryError("Invalid search path: expected an array")
360
359
  }
361
360
 
362
361
  for (const pathEntry of path) {
363
362
  if (typeof pathEntry !== "string" || pathEntry.length < 1) {
364
- throw frontendModelValidationError("Invalid search path entry: expected non-empty string")
363
+ throw frontendModelQueryError("Invalid search path entry: expected non-empty string")
365
364
  }
366
365
  }
367
366
 
368
367
  if (typeof column !== "string" || column.length < 1) {
369
- throw frontendModelValidationError("Invalid search column: expected non-empty string")
368
+ throw frontendModelQueryError("Invalid search column: expected non-empty string")
370
369
  }
371
370
 
372
371
  if (typeof operator !== "string") {
373
- throw frontendModelValidationError(`Invalid search operator: ${operator}`)
372
+ throw frontendModelQueryError(`Invalid search operator: ${operator}`)
373
+ }
374
+
375
+ let normalizedOperator
376
+
377
+ try {
378
+ normalizedOperator = normalizeQuerySearchOperator(operator)
379
+ } catch (error) {
380
+ throwFrontendModelQueryErrorForParserError(error)
374
381
  }
375
382
 
376
383
  normalized.push({
377
384
  column,
378
- operator: normalizeQuerySearchOperator(operator),
385
+ operator: normalizedOperator,
379
386
  path: [...path],
380
387
  value: search.value
381
388
  })
@@ -393,7 +400,7 @@ function normalizeFrontendModelWhere(where) {
393
400
  if (!where) return null
394
401
 
395
402
  if (!isPlainObject(where)) {
396
- throw frontendModelValidationError(`Invalid where type: ${typeof where}`)
403
+ throw frontendModelQueryError(`Invalid where type: ${typeof where}`)
397
404
  }
398
405
 
399
406
  return where
@@ -408,7 +415,7 @@ function normalizeFrontendModelRansack(ransack) {
408
415
  if (!ransack) return null
409
416
 
410
417
  if (!isPlainObject(ransack)) {
411
- throw frontendModelValidationError(`Invalid ransack type: ${typeof ransack}`)
418
+ throw frontendModelQueryError(`Invalid ransack type: ${typeof ransack}`)
412
419
  }
413
420
 
414
421
  return ransack
@@ -425,11 +432,11 @@ function normalizeFrontendModelIntegerParam(value, name, min) {
425
432
  if (value == null) return null
426
433
 
427
434
  if (typeof value !== "number" || !Number.isInteger(value)) {
428
- throw frontendModelValidationError(`Invalid ${name}: expected integer number`)
435
+ throw frontendModelQueryError(`Invalid ${name}: expected integer number`)
429
436
  }
430
437
 
431
438
  if (value < min) {
432
- throw frontendModelValidationError(`Invalid ${name}: expected value >= ${min}`)
439
+ throw frontendModelQueryError(`Invalid ${name}: expected value >= ${min}`)
433
440
  }
434
441
 
435
442
  return value
@@ -462,7 +469,7 @@ function normalizeFrontendModelDistinct(distinct) {
462
469
  if (distinct == null) return null
463
470
 
464
471
  if (typeof distinct !== "boolean") {
465
- throw frontendModelValidationError(`Invalid distinct: expected boolean`)
472
+ throw frontendModelQueryError(`Invalid distinct: expected boolean`)
466
473
  }
467
474
 
468
475
  return distinct
@@ -1194,7 +1201,11 @@ export default class FrontendModelController extends Controller {
1194
1201
  * @returns {FrontendModelSort[]} - Frontend sort definitions.
1195
1202
  */
1196
1203
  frontendModelSort() {
1197
- return normalizeQuerySort(this.frontendModelParams().sort)
1204
+ try {
1205
+ return normalizeQuerySort(this.frontendModelParams().sort)
1206
+ } catch (error) {
1207
+ throwFrontendModelQueryErrorForParserError(error)
1208
+ }
1198
1209
  }
1199
1210
 
1200
1211
  /**
@@ -1205,7 +1216,7 @@ export default class FrontendModelController extends Controller {
1205
1216
  try {
1206
1217
  return normalizeQueryGroup(this.frontendModelParams().group)
1207
1218
  } catch (error) {
1208
- throw frontendModelValidationErrorForError(error)
1219
+ throwFrontendModelQueryErrorForParserError(error)
1209
1220
  }
1210
1221
  }
1211
1222
 
@@ -1240,11 +1251,11 @@ export default class FrontendModelController extends Controller {
1240
1251
  try {
1241
1252
  const pluck = normalizeQueryPluck(this.frontendModelParams().pluck)
1242
1253
 
1243
- this.validateFrontendModelPluckDefinitions(pluck)
1254
+ this.assertFrontendModelPluckDefinitionsAllowed(pluck)
1244
1255
 
1245
1256
  return pluck
1246
1257
  } catch (error) {
1247
- throw frontendModelValidationErrorForError(error)
1258
+ throwFrontendModelQueryErrorForParserError(error)
1248
1259
  }
1249
1260
  }
1250
1261
 
@@ -1517,6 +1528,7 @@ export default class FrontendModelController extends Controller {
1517
1528
  const ransack = this.frontendModelRansack()
1518
1529
 
1519
1530
  if (ransack) {
1531
+ this.assertFrontendModelRansackAllowed(ransack)
1520
1532
  query.ransack(ransack)
1521
1533
  }
1522
1534
 
@@ -1639,7 +1651,7 @@ export default class FrontendModelController extends Controller {
1639
1651
  })
1640
1652
 
1641
1653
  if (!columnName) {
1642
- throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1654
+ throw frontendModelQueryError(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1643
1655
  }
1644
1656
 
1645
1657
  if (pluckEntry.path.length > 0) {
@@ -1735,11 +1747,11 @@ export default class FrontendModelController extends Controller {
1735
1747
  }
1736
1748
 
1737
1749
  /**
1738
- * Validates frontend-model pluck definitions against exposed resource attributes.
1750
+ * Asserts frontend-model pluck definitions only reference exposed resource attributes.
1739
1751
  * @param {FrontendModelPluck[]} pluck - Pluck descriptors.
1740
1752
  * @returns {void}
1741
1753
  */
1742
- validateFrontendModelPluckDefinitions(pluck) {
1754
+ assertFrontendModelPluckDefinitionsAllowed(pluck) {
1743
1755
  const modelClass = this.frontendModelClass()
1744
1756
 
1745
1757
  for (const pluckEntry of pluck) {
@@ -1753,11 +1765,105 @@ export default class FrontendModelController extends Controller {
1753
1765
  })
1754
1766
 
1755
1767
  if (!columnName) {
1756
- throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1768
+ throw frontendModelQueryError(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1769
+ }
1770
+ }
1771
+ }
1772
+
1773
+ /**
1774
+ * Asserts frontend-model Ransack definitions only reference exposed resource attributes.
1775
+ * @param {Record<string, ?>} ransack - Ransack descriptor.
1776
+ * @returns {void}
1777
+ */
1778
+ assertFrontendModelRansackAllowed(ransack) {
1779
+ const {s, ...filterParams} = ransack
1780
+
1781
+ if (Object.keys(filterParams).length > 0) {
1782
+ this.assertFrontendModelRansackGroupAllowed({
1783
+ group: this.frontendModelRansackGroup(filterParams)
1784
+ })
1785
+ }
1786
+
1787
+ if (typeof s === "string" && s.trim().length > 0) {
1788
+ for (const sort of this.frontendModelRansackSorts(s)) {
1789
+ this.assertFrontendModelRansackAttributeAllowed({
1790
+ attributeName: sort.attribute,
1791
+ modelClass: this.frontendModelClass(),
1792
+ operationName: "ransack sort"
1793
+ })
1757
1794
  }
1758
1795
  }
1759
1796
  }
1760
1797
 
1798
+ /**
1799
+ * Runs normalized frontend-model Ransack group.
1800
+ * @param {Record<string, ?>} filterParams - Ransack filter params.
1801
+ * @returns {import("./utils/ransack.js").RansackGroup} Normalized Ransack group.
1802
+ */
1803
+ frontendModelRansackGroup(filterParams) {
1804
+ try {
1805
+ return normalizeRansackGroup(this.frontendModelClass(), filterParams)
1806
+ } catch (error) {
1807
+ throwFrontendModelQueryErrorForParserError(error)
1808
+ }
1809
+ }
1810
+
1811
+ /**
1812
+ * Runs normalized frontend-model Ransack sorts.
1813
+ * @param {string} sortString - Ransack sort string.
1814
+ * @returns {import("./utils/ransack.js").RansackSort[]} Normalized Ransack sorts.
1815
+ */
1816
+ frontendModelRansackSorts(sortString) {
1817
+ try {
1818
+ return parseRansackSort(this.frontendModelClass(), sortString)
1819
+ } catch (error) {
1820
+ throwFrontendModelQueryErrorForParserError(error)
1821
+ }
1822
+ }
1823
+
1824
+ /**
1825
+ * Asserts a normalized frontend-model Ransack group only references exposed attributes.
1826
+ * @param {object} args - Assertion args.
1827
+ * @param {import("./utils/ransack.js").RansackGroup} args.group - Ransack group.
1828
+ * @returns {void}
1829
+ */
1830
+ assertFrontendModelRansackGroupAllowed({group}) {
1831
+ for (const condition of group.conditions) {
1832
+ for (const attribute of condition.attributes) {
1833
+ const targetModelClass = this.frontendModelSearchTargetModelClass({
1834
+ modelClass: this.frontendModelClass(),
1835
+ path: attribute.path
1836
+ })
1837
+
1838
+ this.assertFrontendModelRansackAttributeAllowed({
1839
+ attributeName: attribute.attributeName,
1840
+ modelClass: targetModelClass,
1841
+ operationName: "ransack"
1842
+ })
1843
+ }
1844
+ }
1845
+
1846
+ for (const grouping of group.groupings) {
1847
+ this.assertFrontendModelRansackGroupAllowed({group: grouping})
1848
+ }
1849
+ }
1850
+
1851
+ /**
1852
+ * Asserts one normalized frontend-model Ransack attribute is exposed by its resource.
1853
+ * @param {object} args - Assertion args.
1854
+ * @param {string} args.attributeName - Attribute name.
1855
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Target model class.
1856
+ * @param {string} args.operationName - Operation name for errors.
1857
+ * @returns {void}
1858
+ */
1859
+ assertFrontendModelRansackAttributeAllowed({attributeName, modelClass, operationName}) {
1860
+ const attributeNames = this.frontendModelResourceAttributeNamesForModelClass(modelClass)
1861
+
1862
+ if (attributeNames && !attributeNames.has(attributeName)) {
1863
+ throw frontendModelQueryError(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
1864
+ }
1865
+ }
1866
+
1761
1867
  /**
1762
1868
  * Runs frontend model search target model class.
1763
1869
  * @param {object} args - Search args.
@@ -1772,7 +1878,7 @@ export default class FrontendModelController extends Controller {
1772
1878
  const relationship = targetModelClass.getRelationshipsMap()[relationshipName]
1773
1879
 
1774
1880
  if (!relationship) {
1775
- throw new Error(`Unknown search relationship "${relationshipName}" for ${targetModelClass.name}`)
1881
+ throw frontendModelQueryError(`Unknown search relationship "${relationshipName}" for ${targetModelClass.name}`)
1776
1882
  }
1777
1883
 
1778
1884
  const relationshipTargetModelClass = relationship.getTargetModelClass()
@@ -1807,7 +1913,7 @@ export default class FrontendModelController extends Controller {
1807
1913
  })
1808
1914
 
1809
1915
  if (!columnName) {
1810
- throw new Error(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
1916
+ throw frontendModelQueryError(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
1811
1917
  }
1812
1918
 
1813
1919
  if (search.path.length > 0) {
@@ -1958,7 +2064,7 @@ export default class FrontendModelController extends Controller {
1958
2064
  const relationship = modelClass.getRelationshipsMap()[relationshipName]
1959
2065
 
1960
2066
  if (!relationship) {
1961
- throw new Error(`Unknown join relationship "${relationshipName}" for ${modelClass.name}`)
2067
+ throw frontendModelQueryError(`Unknown join relationship "${relationshipName}" for ${modelClass.name}`)
1962
2068
  }
1963
2069
 
1964
2070
  const targetModelClass = relationship.getTargetModelClass()
@@ -2049,18 +2155,18 @@ export default class FrontendModelController extends Controller {
2049
2155
  }
2050
2156
 
2051
2157
  /**
2052
- * Validates a selected frontend-model attribute list against resource metadata.
2158
+ * Asserts a selected frontend-model attribute list only references exposed attributes.
2053
2159
  * @param {object} args - Args.
2054
2160
  * @param {string[]} args.attributeNames - Selected attribute names.
2055
2161
  * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
2056
2162
  * @param {"select" | "selectsExtra"} args.operationName - Selection operation.
2057
- * @returns {string[]} - Validated selected attribute names.
2163
+ * @returns {string[]} - Allowed selected attribute names.
2058
2164
  */
2059
- validateFrontendModelSelectedAttributes({attributeNames, modelClass, operationName}) {
2165
+ assertFrontendModelSelectedAttributesAllowed({attributeNames, modelClass, operationName}) {
2060
2166
  for (const attributeName of attributeNames) {
2061
2167
  if (this.frontendModelAttributeIsExposed({attributeName, modelClass})) continue
2062
2168
 
2063
- throw new Error(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2169
+ throw frontendModelQueryError(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2064
2170
  }
2065
2171
 
2066
2172
  return attributeNames
@@ -2163,7 +2269,7 @@ export default class FrontendModelController extends Controller {
2163
2269
  const relationship = modelClass.getRelationshipsMap()[attributeName]
2164
2270
 
2165
2271
  if (!relationship) {
2166
- throw new Error(`Unknown where relationship "${attributeName}" for ${modelClass.name}`)
2272
+ throw frontendModelQueryError(`Unknown where relationship "${attributeName}" for ${modelClass.name}`)
2167
2273
  }
2168
2274
 
2169
2275
  const targetModelClass = relationship.getTargetModelClass()
@@ -2184,7 +2290,7 @@ export default class FrontendModelController extends Controller {
2184
2290
  continue
2185
2291
  }
2186
2292
 
2187
- throw new Error(`Unknown where column "${attributeName}" for ${modelClass.name}`)
2293
+ throw frontendModelQueryError(`Unknown where column "${attributeName}" for ${modelClass.name}`)
2188
2294
  }
2189
2295
  }
2190
2296
 
@@ -2251,7 +2357,7 @@ export default class FrontendModelController extends Controller {
2251
2357
  })
2252
2358
 
2253
2359
  if (!columnName) {
2254
- throw new Error(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
2360
+ throw frontendModelQueryError(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
2255
2361
  }
2256
2362
 
2257
2363
  this.ensureFrontendModelJoinPath({path: group.path, query})
@@ -2369,7 +2475,7 @@ export default class FrontendModelController extends Controller {
2369
2475
  const translationPath = sort.path.concat(["currentTranslation"])
2370
2476
 
2371
2477
  if (!translationColumnName) {
2372
- throw new Error(`Unknown translated sort column "${sort.column}" for ${targetModelClass.name}`)
2478
+ throw frontendModelQueryError(`Unknown translated sort column "${sort.column}" for ${targetModelClass.name}`)
2373
2479
  }
2374
2480
 
2375
2481
  this.ensureFrontendModelSortJoinPath({path: translationPath, query})
@@ -2383,7 +2489,7 @@ export default class FrontendModelController extends Controller {
2383
2489
  }
2384
2490
 
2385
2491
  if (!columnName) {
2386
- throw new Error(`Unknown sort column "${sort.column}" for ${targetModelClass.name}`)
2492
+ throw frontendModelQueryError(`Unknown sort column "${sort.column}" for ${targetModelClass.name}`)
2387
2493
  }
2388
2494
 
2389
2495
  this.ensureFrontendModelSortJoinPath({path: sort.path, query})
@@ -2440,7 +2546,7 @@ export default class FrontendModelController extends Controller {
2440
2546
 
2441
2547
  if (!selectedAttributes) return null
2442
2548
 
2443
- return this.validateFrontendModelSelectedAttributes({
2549
+ return this.assertFrontendModelSelectedAttributesAllowed({
2444
2550
  attributeNames: selectedAttributes,
2445
2551
  modelClass,
2446
2552
  operationName: "select"
@@ -2461,7 +2567,7 @@ export default class FrontendModelController extends Controller {
2461
2567
 
2462
2568
  if (!extraAttributes) return null
2463
2569
 
2464
- return this.validateFrontendModelSelectedAttributes({
2570
+ return this.assertFrontendModelSelectedAttributesAllowed({
2465
2571
  attributeNames: extraAttributes,
2466
2572
  modelClass,
2467
2573
  operationName: "selectsExtra"