velocious 1.0.466 → 1.0.468

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 (34) 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/environment-handlers/node/cli/commands/generate/frontend-models.js +155 -35
  6. package/build/frontend-model-controller.js +209 -57
  7. package/build/frontend-models/query.js +55 -33
  8. package/build/src/configuration.js +3 -3
  9. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  10. package/build/src/database/pool/async-tracked-multi-connection.js +9 -3
  11. package/build/src/database/pool/single-multi-use.d.ts +1 -0
  12. package/build/src/database/pool/single-multi-use.d.ts.map +1 -1
  13. package/build/src/database/pool/single-multi-use.js +34 -1
  14. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +82 -9
  15. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  16. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +137 -35
  17. package/build/src/frontend-model-controller.d.ts +61 -5
  18. package/build/src/frontend-model-controller.d.ts.map +1 -1
  19. package/build/src/frontend-model-controller.js +190 -58
  20. package/build/src/frontend-models/query.d.ts +8 -0
  21. package/build/src/frontend-models/query.d.ts.map +1 -1
  22. package/build/src/frontend-models/query.js +53 -34
  23. package/build/src/utils/ransack.d.ts +11 -3
  24. package/build/src/utils/ransack.d.ts.map +1 -1
  25. package/build/src/utils/ransack.js +42 -23
  26. package/build/utils/ransack.js +44 -22
  27. package/package.json +1 -1
  28. package/src/configuration.js +2 -2
  29. package/src/database/pool/async-tracked-multi-connection.js +7 -2
  30. package/src/database/pool/single-multi-use.js +38 -0
  31. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +155 -35
  32. package/src/frontend-model-controller.js +209 -57
  33. package/src/frontend-models/query.js +55 -33
  34. package/src/utils/ransack.js +44 -22
@@ -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
@@ -577,6 +584,12 @@ export default class FrontendModelController extends Controller {
577
584
  * Frontend model ability override.
578
585
  * @type {import("./authorization/ability.js").default | undefined} */
579
586
  _frontendModelAbilityOverride = undefined
587
+ /**
588
+ * Original deserialized custom-command client payload, captured before route
589
+ * framework params are merged in, so a typed command method receives the client's
590
+ * own arguments rather than the route metadata. Only set on the shared-endpoint path.
591
+ * @type {Record<string, ?> | undefined} */
592
+ _frontendModelCustomCommandClientArguments = undefined
580
593
 
581
594
  /**
582
595
  * Runs frontend model params.
@@ -1194,7 +1207,11 @@ export default class FrontendModelController extends Controller {
1194
1207
  * @returns {FrontendModelSort[]} - Frontend sort definitions.
1195
1208
  */
1196
1209
  frontendModelSort() {
1197
- return normalizeQuerySort(this.frontendModelParams().sort)
1210
+ try {
1211
+ return normalizeQuerySort(this.frontendModelParams().sort)
1212
+ } catch (error) {
1213
+ throwFrontendModelQueryErrorForParserError(error)
1214
+ }
1198
1215
  }
1199
1216
 
1200
1217
  /**
@@ -1205,7 +1222,7 @@ export default class FrontendModelController extends Controller {
1205
1222
  try {
1206
1223
  return normalizeQueryGroup(this.frontendModelParams().group)
1207
1224
  } catch (error) {
1208
- throw frontendModelValidationErrorForError(error)
1225
+ throwFrontendModelQueryErrorForParserError(error)
1209
1226
  }
1210
1227
  }
1211
1228
 
@@ -1240,11 +1257,11 @@ export default class FrontendModelController extends Controller {
1240
1257
  try {
1241
1258
  const pluck = normalizeQueryPluck(this.frontendModelParams().pluck)
1242
1259
 
1243
- this.validateFrontendModelPluckDefinitions(pluck)
1260
+ this.assertFrontendModelPluckDefinitionsAllowed(pluck)
1244
1261
 
1245
1262
  return pluck
1246
1263
  } catch (error) {
1247
- throw frontendModelValidationErrorForError(error)
1264
+ throwFrontendModelQueryErrorForParserError(error)
1248
1265
  }
1249
1266
  }
1250
1267
 
@@ -1517,6 +1534,7 @@ export default class FrontendModelController extends Controller {
1517
1534
  const ransack = this.frontendModelRansack()
1518
1535
 
1519
1536
  if (ransack) {
1537
+ this.assertFrontendModelRansackAllowed(ransack)
1520
1538
  query.ransack(ransack)
1521
1539
  }
1522
1540
 
@@ -1639,7 +1657,7 @@ export default class FrontendModelController extends Controller {
1639
1657
  })
1640
1658
 
1641
1659
  if (!columnName) {
1642
- throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1660
+ throw frontendModelQueryError(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1643
1661
  }
1644
1662
 
1645
1663
  if (pluckEntry.path.length > 0) {
@@ -1735,11 +1753,11 @@ export default class FrontendModelController extends Controller {
1735
1753
  }
1736
1754
 
1737
1755
  /**
1738
- * Validates frontend-model pluck definitions against exposed resource attributes.
1756
+ * Asserts frontend-model pluck definitions only reference exposed resource attributes.
1739
1757
  * @param {FrontendModelPluck[]} pluck - Pluck descriptors.
1740
1758
  * @returns {void}
1741
1759
  */
1742
- validateFrontendModelPluckDefinitions(pluck) {
1760
+ assertFrontendModelPluckDefinitionsAllowed(pluck) {
1743
1761
  const modelClass = this.frontendModelClass()
1744
1762
 
1745
1763
  for (const pluckEntry of pluck) {
@@ -1753,9 +1771,103 @@ export default class FrontendModelController extends Controller {
1753
1771
  })
1754
1772
 
1755
1773
  if (!columnName) {
1756
- throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1774
+ throw frontendModelQueryError(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
1775
+ }
1776
+ }
1777
+ }
1778
+
1779
+ /**
1780
+ * Asserts frontend-model Ransack definitions only reference exposed resource attributes.
1781
+ * @param {Record<string, ?>} ransack - Ransack descriptor.
1782
+ * @returns {void}
1783
+ */
1784
+ assertFrontendModelRansackAllowed(ransack) {
1785
+ const {s, ...filterParams} = ransack
1786
+
1787
+ if (Object.keys(filterParams).length > 0) {
1788
+ this.assertFrontendModelRansackGroupAllowed({
1789
+ group: this.frontendModelRansackGroup(filterParams)
1790
+ })
1791
+ }
1792
+
1793
+ if (typeof s === "string" && s.trim().length > 0) {
1794
+ for (const sort of this.frontendModelRansackSorts(s)) {
1795
+ this.assertFrontendModelRansackAttributeAllowed({
1796
+ attributeName: sort.attribute,
1797
+ modelClass: this.frontendModelClass(),
1798
+ operationName: "ransack sort"
1799
+ })
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ /**
1805
+ * Runs normalized frontend-model Ransack group.
1806
+ * @param {Record<string, ?>} filterParams - Ransack filter params.
1807
+ * @returns {import("./utils/ransack.js").RansackGroup} Normalized Ransack group.
1808
+ */
1809
+ frontendModelRansackGroup(filterParams) {
1810
+ try {
1811
+ return normalizeRansackGroup(this.frontendModelClass(), filterParams)
1812
+ } catch (error) {
1813
+ throwFrontendModelQueryErrorForParserError(error)
1814
+ }
1815
+ }
1816
+
1817
+ /**
1818
+ * Runs normalized frontend-model Ransack sorts.
1819
+ * @param {string} sortString - Ransack sort string.
1820
+ * @returns {import("./utils/ransack.js").RansackSort[]} Normalized Ransack sorts.
1821
+ */
1822
+ frontendModelRansackSorts(sortString) {
1823
+ try {
1824
+ return parseRansackSort(this.frontendModelClass(), sortString)
1825
+ } catch (error) {
1826
+ throwFrontendModelQueryErrorForParserError(error)
1827
+ }
1828
+ }
1829
+
1830
+ /**
1831
+ * Asserts a normalized frontend-model Ransack group only references exposed attributes.
1832
+ * @param {object} args - Assertion args.
1833
+ * @param {import("./utils/ransack.js").RansackGroup} args.group - Ransack group.
1834
+ * @returns {void}
1835
+ */
1836
+ assertFrontendModelRansackGroupAllowed({group}) {
1837
+ for (const condition of group.conditions) {
1838
+ for (const attribute of condition.attributes) {
1839
+ const targetModelClass = this.frontendModelSearchTargetModelClass({
1840
+ modelClass: this.frontendModelClass(),
1841
+ path: attribute.path
1842
+ })
1843
+
1844
+ this.assertFrontendModelRansackAttributeAllowed({
1845
+ attributeName: attribute.attributeName,
1846
+ modelClass: targetModelClass,
1847
+ operationName: "ransack"
1848
+ })
1757
1849
  }
1758
1850
  }
1851
+
1852
+ for (const grouping of group.groupings) {
1853
+ this.assertFrontendModelRansackGroupAllowed({group: grouping})
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Asserts one normalized frontend-model Ransack attribute is exposed by its resource.
1859
+ * @param {object} args - Assertion args.
1860
+ * @param {string} args.attributeName - Attribute name.
1861
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Target model class.
1862
+ * @param {string} args.operationName - Operation name for errors.
1863
+ * @returns {void}
1864
+ */
1865
+ assertFrontendModelRansackAttributeAllowed({attributeName, modelClass, operationName}) {
1866
+ const attributeNames = this.frontendModelResourceAttributeNamesForModelClass(modelClass)
1867
+
1868
+ if (attributeNames && !attributeNames.has(attributeName)) {
1869
+ throw frontendModelQueryError(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
1870
+ }
1759
1871
  }
1760
1872
 
1761
1873
  /**
@@ -1772,7 +1884,7 @@ export default class FrontendModelController extends Controller {
1772
1884
  const relationship = targetModelClass.getRelationshipsMap()[relationshipName]
1773
1885
 
1774
1886
  if (!relationship) {
1775
- throw new Error(`Unknown search relationship "${relationshipName}" for ${targetModelClass.name}`)
1887
+ throw frontendModelQueryError(`Unknown search relationship "${relationshipName}" for ${targetModelClass.name}`)
1776
1888
  }
1777
1889
 
1778
1890
  const relationshipTargetModelClass = relationship.getTargetModelClass()
@@ -1807,7 +1919,7 @@ export default class FrontendModelController extends Controller {
1807
1919
  })
1808
1920
 
1809
1921
  if (!columnName) {
1810
- throw new Error(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
1922
+ throw frontendModelQueryError(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
1811
1923
  }
1812
1924
 
1813
1925
  if (search.path.length > 0) {
@@ -1958,7 +2070,7 @@ export default class FrontendModelController extends Controller {
1958
2070
  const relationship = modelClass.getRelationshipsMap()[relationshipName]
1959
2071
 
1960
2072
  if (!relationship) {
1961
- throw new Error(`Unknown join relationship "${relationshipName}" for ${modelClass.name}`)
2073
+ throw frontendModelQueryError(`Unknown join relationship "${relationshipName}" for ${modelClass.name}`)
1962
2074
  }
1963
2075
 
1964
2076
  const targetModelClass = relationship.getTargetModelClass()
@@ -2049,18 +2161,18 @@ export default class FrontendModelController extends Controller {
2049
2161
  }
2050
2162
 
2051
2163
  /**
2052
- * Validates a selected frontend-model attribute list against resource metadata.
2164
+ * Asserts a selected frontend-model attribute list only references exposed attributes.
2053
2165
  * @param {object} args - Args.
2054
2166
  * @param {string[]} args.attributeNames - Selected attribute names.
2055
2167
  * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
2056
2168
  * @param {"select" | "selectsExtra"} args.operationName - Selection operation.
2057
- * @returns {string[]} - Validated selected attribute names.
2169
+ * @returns {string[]} - Allowed selected attribute names.
2058
2170
  */
2059
- validateFrontendModelSelectedAttributes({attributeNames, modelClass, operationName}) {
2171
+ assertFrontendModelSelectedAttributesAllowed({attributeNames, modelClass, operationName}) {
2060
2172
  for (const attributeName of attributeNames) {
2061
2173
  if (this.frontendModelAttributeIsExposed({attributeName, modelClass})) continue
2062
2174
 
2063
- throw new Error(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2175
+ throw frontendModelQueryError(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2064
2176
  }
2065
2177
 
2066
2178
  return attributeNames
@@ -2163,7 +2275,7 @@ export default class FrontendModelController extends Controller {
2163
2275
  const relationship = modelClass.getRelationshipsMap()[attributeName]
2164
2276
 
2165
2277
  if (!relationship) {
2166
- throw new Error(`Unknown where relationship "${attributeName}" for ${modelClass.name}`)
2278
+ throw frontendModelQueryError(`Unknown where relationship "${attributeName}" for ${modelClass.name}`)
2167
2279
  }
2168
2280
 
2169
2281
  const targetModelClass = relationship.getTargetModelClass()
@@ -2184,7 +2296,7 @@ export default class FrontendModelController extends Controller {
2184
2296
  continue
2185
2297
  }
2186
2298
 
2187
- throw new Error(`Unknown where column "${attributeName}" for ${modelClass.name}`)
2299
+ throw frontendModelQueryError(`Unknown where column "${attributeName}" for ${modelClass.name}`)
2188
2300
  }
2189
2301
  }
2190
2302
 
@@ -2251,7 +2363,7 @@ export default class FrontendModelController extends Controller {
2251
2363
  })
2252
2364
 
2253
2365
  if (!columnName) {
2254
- throw new Error(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
2366
+ throw frontendModelQueryError(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
2255
2367
  }
2256
2368
 
2257
2369
  this.ensureFrontendModelJoinPath({path: group.path, query})
@@ -2369,7 +2481,7 @@ export default class FrontendModelController extends Controller {
2369
2481
  const translationPath = sort.path.concat(["currentTranslation"])
2370
2482
 
2371
2483
  if (!translationColumnName) {
2372
- throw new Error(`Unknown translated sort column "${sort.column}" for ${targetModelClass.name}`)
2484
+ throw frontendModelQueryError(`Unknown translated sort column "${sort.column}" for ${targetModelClass.name}`)
2373
2485
  }
2374
2486
 
2375
2487
  this.ensureFrontendModelSortJoinPath({path: translationPath, query})
@@ -2383,7 +2495,7 @@ export default class FrontendModelController extends Controller {
2383
2495
  }
2384
2496
 
2385
2497
  if (!columnName) {
2386
- throw new Error(`Unknown sort column "${sort.column}" for ${targetModelClass.name}`)
2498
+ throw frontendModelQueryError(`Unknown sort column "${sort.column}" for ${targetModelClass.name}`)
2387
2499
  }
2388
2500
 
2389
2501
  this.ensureFrontendModelSortJoinPath({path: sort.path, query})
@@ -2440,7 +2552,7 @@ export default class FrontendModelController extends Controller {
2440
2552
 
2441
2553
  if (!selectedAttributes) return null
2442
2554
 
2443
- return this.validateFrontendModelSelectedAttributes({
2555
+ return this.assertFrontendModelSelectedAttributesAllowed({
2444
2556
  attributeNames: selectedAttributes,
2445
2557
  modelClass,
2446
2558
  operationName: "select"
@@ -2461,7 +2573,7 @@ export default class FrontendModelController extends Controller {
2461
2573
 
2462
2574
  if (!extraAttributes) return null
2463
2575
 
2464
- return this.validateFrontendModelSelectedAttributes({
2576
+ return this.assertFrontendModelSelectedAttributesAllowed({
2465
2577
  attributeNames: extraAttributes,
2466
2578
  modelClass,
2467
2579
  operationName: "selectsExtra"
@@ -3472,6 +3584,14 @@ export default class FrontendModelController extends Controller {
3472
3584
  viewPath
3473
3585
  })
3474
3586
 
3587
+ // Preserve the client's own command arguments before route framework params won
3588
+ // the `controllerParams` merge above, so a typed command method (`async name(args)`)
3589
+ // receives the client payload — not the route's member id / model / controller keys.
3590
+ const customCommandController = /** @type {FrontendModelController} */ (/** @type {unknown} */ (controllerInstance))
3591
+
3592
+ customCommandController._frontendModelCustomCommandClientArguments =
3593
+ (payload && typeof payload === "object" && !Array.isArray(payload)) ? /** @type {Record<string, ?>} */ (payload) : {}
3594
+
3475
3595
  await this.withFrontendModelRequestContext(controllerParams, response, async () => {
3476
3596
  await controllerInstance._runBeforeCallbacks()
3477
3597
  const controllerMethods = /** @type {Record<string, () => Promise<void> | void>} */ (/** @type {?} */ (controllerInstance))
@@ -3651,7 +3771,13 @@ export default class FrontendModelController extends Controller {
3651
3771
  return this.frontendModelErrorPayload(`Missing frontend-model custom command '${methodName}'.`)
3652
3772
  }
3653
3773
 
3654
- const responsePayload = await commandMethod.call(resource)
3774
+ // Pass the client command arguments as the method's first argument so a command
3775
+ // method can take a typed args object (`async name(args)`) and the generated
3776
+ // frontend method can forward the backend method's `@param`. `this.params()` is
3777
+ // unchanged, so existing parameterless methods keep working. The args are untrusted
3778
+ // client input typed only by the declared contract, so methods must still validate.
3779
+ const commandArguments = this.frontendModelCustomCommandArguments(params)
3780
+ const responsePayload = await commandMethod.call(resource, commandArguments)
3655
3781
 
3656
3782
  if (!responsePayload || typeof responsePayload !== "object") {
3657
3783
  return {status: "success"}
@@ -3666,6 +3792,32 @@ export default class FrontendModelController extends Controller {
3666
3792
  )
3667
3793
  }
3668
3794
 
3795
+ /**
3796
+ * Resolves the typed argument object passed to a custom command method. On the
3797
+ * shared-endpoint path the original client payload was captured before route
3798
+ * framework params were merged, so it is returned verbatim (a client `id` survives
3799
+ * a member route). On the direct path it falls back to the request params with the
3800
+ * framework keys the command route hook injected stripped out.
3801
+ * @param {Record<string, ?>} params - Deserialized frontend-model params.
3802
+ * @returns {Record<string, ?>} - Client command arguments.
3803
+ */
3804
+ frontendModelCustomCommandArguments(params) {
3805
+ if (this._frontendModelCustomCommandClientArguments) {
3806
+ return this._frontendModelCustomCommandClientArguments
3807
+ }
3808
+
3809
+ const {
3810
+ action: _action,
3811
+ controller: _controller,
3812
+ frontendModelCustomCommandMethodName: _methodName,
3813
+ frontendModelCustomCommandScope: _scope,
3814
+ model: _model,
3815
+ ...commandArguments
3816
+ } = params
3817
+
3818
+ return commandArguments
3819
+ }
3820
+
3669
3821
  /**
3670
3822
  * Walks a custom-command response payload and replaces any backend `Record`
3671
3823
  * instance with the resource's per-action serialized form so handlers can