velocious 1.0.451 → 1.0.453

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 (78) hide show
  1. package/README.md +11 -5
  2. package/build/configuration-types.js +23 -0
  3. package/build/configuration.js +5 -5
  4. package/build/database/drivers/mssql/index.js +1 -1
  5. package/build/database/drivers/mysql/index.js +1 -1
  6. package/build/database/drivers/pgsql/index.js +1 -1
  7. package/build/database/drivers/sqlite/base.js +1 -1
  8. package/build/database/record/attachments/attachment-record.js +121 -0
  9. package/build/database/record/attachments/store.js +2 -0
  10. package/build/database/table-data/index.js +1 -1
  11. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -2
  12. package/build/frontend-model-controller.js +302 -113
  13. package/build/frontend-model-resource/base-resource.js +2 -1
  14. package/build/frontend-model-resource/velocious-attachment-resource.js +221 -0
  15. package/build/frontend-models/base.js +127 -1
  16. package/build/frontend-models/built-in-resources.js +32 -0
  17. package/build/frontend-models/websocket-publishers.js +13 -3
  18. package/build/routes/hooks/frontend-model-command-route-hook.js +3 -2
  19. package/build/src/configuration-types.d.ts +56 -0
  20. package/build/src/configuration-types.d.ts.map +1 -1
  21. package/build/src/configuration-types.js +21 -1
  22. package/build/src/configuration.d.ts +9 -17
  23. package/build/src/configuration.d.ts.map +1 -1
  24. package/build/src/configuration.js +6 -6
  25. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  26. package/build/src/database/drivers/mssql/index.js +2 -2
  27. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  28. package/build/src/database/drivers/mysql/index.js +2 -2
  29. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  30. package/build/src/database/drivers/pgsql/index.js +2 -2
  31. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  32. package/build/src/database/drivers/sqlite/base.js +2 -2
  33. package/build/src/database/record/attachments/attachment-record.d.ts +67 -0
  34. package/build/src/database/record/attachments/attachment-record.d.ts.map +1 -0
  35. package/build/src/database/record/attachments/attachment-record.js +106 -0
  36. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  37. package/build/src/database/record/attachments/store.js +2 -1
  38. package/build/src/database/table-data/index.js +2 -2
  39. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  40. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -3
  41. package/build/src/frontend-model-controller.d.ts +82 -9
  42. package/build/src/frontend-model-controller.d.ts.map +1 -1
  43. package/build/src/frontend-model-controller.js +270 -112
  44. package/build/src/frontend-model-resource/base-resource.d.ts +2 -0
  45. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  46. package/build/src/frontend-model-resource/base-resource.js +3 -2
  47. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +105 -0
  48. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -0
  49. package/build/src/frontend-model-resource/velocious-attachment-resource.js +185 -0
  50. package/build/src/frontend-models/base.d.ts +87 -1
  51. package/build/src/frontend-models/base.d.ts.map +1 -1
  52. package/build/src/frontend-models/base.js +112 -2
  53. package/build/src/frontend-models/built-in-resources.d.ts +18 -0
  54. package/build/src/frontend-models/built-in-resources.d.ts.map +1 -0
  55. package/build/src/frontend-models/built-in-resources.js +29 -0
  56. package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
  57. package/build/src/frontend-models/websocket-publishers.js +14 -4
  58. package/build/src/routes/hooks/frontend-model-command-route-hook.d.ts.map +1 -1
  59. package/build/src/routes/hooks/frontend-model-command-route-hook.js +4 -3
  60. package/build/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +1 -1
  62. package/src/configuration-types.js +23 -0
  63. package/src/configuration.js +5 -5
  64. package/src/database/drivers/mssql/index.js +1 -1
  65. package/src/database/drivers/mysql/index.js +1 -1
  66. package/src/database/drivers/pgsql/index.js +1 -1
  67. package/src/database/drivers/sqlite/base.js +1 -1
  68. package/src/database/record/attachments/attachment-record.js +121 -0
  69. package/src/database/record/attachments/store.js +2 -0
  70. package/src/database/table-data/index.js +1 -1
  71. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +7 -2
  72. package/src/frontend-model-controller.js +302 -113
  73. package/src/frontend-model-resource/base-resource.js +2 -1
  74. package/src/frontend-model-resource/velocious-attachment-resource.js +221 -0
  75. package/src/frontend-models/base.js +127 -1
  76. package/src/frontend-models/built-in-resources.js +32 -0
  77. package/src/frontend-models/websocket-publishers.js +13 -3
  78. package/src/routes/hooks/frontend-model-command-route-hook.js +3 -2
@@ -2,8 +2,10 @@
2
2
 
3
3
  import * as inflection from "inflection"
4
4
  import Controller from "./controller.js"
5
+ import FrontendModelBaseResource from "./frontend-model-resource/base-resource.js"
5
6
  import Response from "./http-server/client/response.js"
6
- import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
7
+ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./frontend-models/built-in-resources.js"
8
+ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcePath} from "./frontend-models/resource-definition.js"
7
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"
8
10
  import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
9
11
  import RoutesResolver from "./routes/resolver.js"
@@ -139,6 +141,14 @@ function normalizeFrontendModelSelect(select, rootModelName = null) {
139
141
  * @property {number | null} perPage - Page size.
140
142
  */
141
143
 
144
+ /**
145
+ * @typedef {import("./configuration-types.js").ClientErrorPayloadContext & {
146
+ * action: string,
147
+ * expectedError: boolean,
148
+ * frontendModelEndpoint: true
149
+ * }} FrontendModelEndpointErrorContext
150
+ */
151
+
142
152
  const frontendModelJoinedPathsSymbol = Symbol("frontendModelJoinedPaths")
143
153
  const frontendModelGroupedColumnsSymbol = Symbol("frontendModelGroupedColumns")
144
154
  const frontendModelWhereNoMatchSymbol = Symbol("frontendModelWhereNoMatch")
@@ -184,32 +194,69 @@ function frontendModelValidationErrorForError(error) {
184
194
  * developer for the frontend" — the framework treats it as
185
195
  * user-facing: surface the message, forward the metadata, and skip
186
196
  * the noisy endpoint-error log.
187
- * @param {?} error - Caught error.
188
- * @returns {boolean}
197
+ * @param {unknown} error - Caught error.
198
+ * @returns {boolean} Whether the error has Velocious frontend metadata.
189
199
  */
190
200
  function frontendModelErrorHasVelociousMetadata(error) {
191
- return Boolean(error && typeof error === "object" && /**
192
- * Types the following value.
193
- * @type {?} */ (error).velocious && typeof /**
194
- * Types the following value.
195
- * @type {?} */ (error).velocious === "object")
201
+ if (!error || typeof error !== "object") return false
202
+
203
+ // Runtime checks above narrow this caught value to the metadata record shape.
204
+ const errorRecord = /** @type {{velocious?: import("./configuration-types.js").ClientErrorPayloadReporterPayload}} */ (error)
205
+
206
+ return isPlainObject(errorRecord.velocious)
207
+ }
208
+
209
+ /**
210
+ * Whether the error has a frontend-model error type marker.
211
+ * @param {unknown} error - Caught error.
212
+ * @returns {boolean} Whether the error has an error type.
213
+ */
214
+ function frontendModelErrorHasErrorType(error) {
215
+ if (!error || typeof error !== "object") return false
216
+
217
+ // Runtime checks above narrow this caught value to the marker record shape.
218
+ const errorRecord = /** @type {{errorType?: string}} */ (error)
219
+
220
+ return typeof errorRecord.errorType === "string" && errorRecord.errorType.length > 0
221
+ }
222
+
223
+ /**
224
+ * Whether the error is an expected frontend-model user-flow failure.
225
+ * @param {unknown} error - Caught error.
226
+ * @returns {boolean} Whether the error is expected.
227
+ */
228
+ function frontendModelExpectedError(error) {
229
+ if (error instanceof ValidationError) return true
230
+ if (error instanceof VelociousError && error.safeToExpose) return true
231
+ if (frontendModelErrorHasVelociousMetadata(error)) return true
232
+
233
+ return frontendModelErrorHasErrorType(error)
196
234
  }
197
235
 
198
236
  /**
199
237
  * Runs frontend model velocious metadata for error.
200
- * @param {?} error - Caught error.
201
- * @returns {Record<string, ?> | null}
238
+ * @param {unknown} error - Caught error.
239
+ * @returns {import("./configuration-types.js").ClientErrorPayloadReporterPayload | null} Frontend-model Velocious metadata when present.
202
240
  */
203
241
  function frontendModelVelociousMetadataForError(error) {
204
- if (!frontendModelErrorHasVelociousMetadata(error)) return null
205
- return /** @type {Record<string, ?>} */ (/**
206
- * Types the following value.
207
- * @type {?} */ (error).velocious)
242
+ const errorCode = error instanceof VelociousError && error.safeToExpose && typeof error.code === "string" && error.code.length > 0
243
+ ? error.code
244
+ : null
245
+
246
+ if (!frontendModelErrorHasVelociousMetadata(error)) {
247
+ return errorCode ? {code: errorCode} : null
248
+ }
249
+
250
+ // frontendModelErrorHasVelociousMetadata guards the caught value before this cast.
251
+ const errorRecord = /** @type {{velocious: import("./configuration-types.js").ClientErrorPayloadReporterPayload}} */ (error)
252
+ const metadata = errorRecord.velocious
253
+
254
+ return errorCode ? {...metadata, code: errorCode} : metadata
208
255
  }
209
256
 
210
257
  /**
211
258
  * Runs frontend model client message for error.
212
- * @param {?} error - Caught error.
259
+ * @param {unknown} error - Caught error.
213
260
  * @returns {string} - Message safe to return to API clients.
214
261
  */
215
262
  function frontendModelClientMessageForError(error) {
@@ -237,8 +284,8 @@ function frontendModelClientMessageForError(error) {
237
284
  * @param {object} args - Arguments.
238
285
  * @param {import("./configuration.js").default} args.configuration - Current configuration.
239
286
  * @param {string} args.environment - Current environment.
240
- * @param {?} args.error - Caught error.
241
- * @returns {Record<string, ?>} - Optional debug payload for non-production environments.
287
+ * @param {unknown} args.error - Caught error.
288
+ * @returns {import("./configuration-types.js").ClientErrorPayloadReporterPayload} - Optional debug payload for non-production environments.
242
289
  */
243
290
  function frontendModelDebugPayloadForError({configuration, environment, error}) {
244
291
  const debugAllowed = frontendModelDebugErrorEnvironments.has(environment) || environment !== "production" && configuration.getExposeInternalErrorsToClients()
@@ -634,7 +681,7 @@ export default class FrontendModelController extends Controller {
634
681
  const backendProjects = this.getConfiguration().getBackendProjects()
635
682
 
636
683
  for (const backendProject of backendProjects) {
637
- const resources = frontendModelResourcesForBackendProject(backendProject)
684
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
638
685
 
639
686
  if (modelName && modelName.length > 0 && resources[modelName]) {
640
687
  const resourceDefinition = resources[modelName]
@@ -688,7 +735,7 @@ export default class FrontendModelController extends Controller {
688
735
  * @returns {{backendProject: import("./configuration-types.js").BackendProjectConfiguration, modelName: string, resourceClass: import("./configuration-types.js").FrontendModelResourceClassType, resourceConfiguration: import("./configuration-types.js").NormalizedFrontendModelResourceConfiguration} | null} - Frontend model resource configuration for model name.
689
736
  */
690
737
  frontendModelResourceConfigurationForBackendProjectModelName({backendProject, modelName}) {
691
- const resources = frontendModelResourcesForBackendProject(backendProject)
738
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
692
739
  const resourceDefinition = resources[modelName]
693
740
 
694
741
  if (!resourceDefinition) return null
@@ -982,16 +1029,31 @@ export default class FrontendModelController extends Controller {
982
1029
  }
983
1030
 
984
1031
  /**
985
- * Runs frontend model authorized query.
1032
+ * Runs frontend model ability authorized query.
986
1033
  * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} action - Frontend action.
987
1034
  * @returns {import("./database/query/model-class-query.js").default<typeof import("./database/record/index.js").default>} - Authorized query for the action.
988
1035
  */
989
- frontendModelAuthorizedQuery(action) {
1036
+ frontendModelAbilityAuthorizedQuery(action) {
990
1037
  const abilityAction = this.frontendModelAbilityAction(action)
991
1038
 
992
1039
  return this.frontendModelClass().accessibleFor(abilityAction, this.currentAbility())
993
1040
  }
994
1041
 
1042
+ /**
1043
+ * Runs frontend model authorized query.
1044
+ * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url"} action - Frontend action.
1045
+ * @returns {import("./database/query/model-class-query.js").default<typeof import("./database/record/index.js").default>} - Authorized query for the action.
1046
+ */
1047
+ frontendModelAuthorizedQuery(action) {
1048
+ const resource = this.frontendModelResourceInstance()
1049
+
1050
+ if (resource.authorizedQuery !== FrontendModelBaseResource.prototype.authorizedQuery) {
1051
+ return resource.authorizedQuery(action)
1052
+ }
1053
+
1054
+ return this.frontendModelAbilityAuthorizedQuery(action)
1055
+ }
1056
+
995
1057
  /**
996
1058
  * Runs frontend model primary key value.
997
1059
  * @param {import("./database/record/index.js").default} model - Model instance.
@@ -1593,7 +1655,11 @@ export default class FrontendModelController extends Controller {
1593
1655
  modelClass,
1594
1656
  path: pluckEntry.path
1595
1657
  })
1596
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, pluckEntry.column)
1658
+ const columnName = this.resolveFrontendModelQueryableColumnName({
1659
+ attributeName: pluckEntry.column,
1660
+ modelClass: targetModelClass,
1661
+ operationName: "pluck"
1662
+ })
1597
1663
 
1598
1664
  if (!columnName) {
1599
1665
  throw new Error(`Unknown pluck column "${pluckEntry.column}" for ${targetModelClass.name}`)
@@ -1672,7 +1738,11 @@ export default class FrontendModelController extends Controller {
1672
1738
  modelClass,
1673
1739
  path: search.path
1674
1740
  })
1675
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, search.column)
1741
+ const columnName = this.resolveFrontendModelQueryableColumnName({
1742
+ attributeName: search.column,
1743
+ modelClass: targetModelClass,
1744
+ operationName: "search"
1745
+ })
1676
1746
 
1677
1747
  if (!columnName) {
1678
1748
  throw new Error(`Unknown search column "${search.column}" for ${targetModelClass.name}`)
@@ -1850,6 +1920,110 @@ export default class FrontendModelController extends Controller {
1850
1920
  }
1851
1921
  }
1852
1922
 
1923
+ /**
1924
+ * Runs frontend model exposed attribute names for model class.
1925
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
1926
+ * @returns {Set<string> | null} - Exposed attribute names, or null when no resource metadata is available.
1927
+ */
1928
+ frontendModelExposedAttributeNamesForModelClass(modelClass) {
1929
+ const frontendModelResource = this.frontendModelResourceConfigurationForModelClass(modelClass)
1930
+ const attributes = frontendModelResource?.resourceConfiguration.attributes
1931
+
1932
+ if (!attributes) return null
1933
+
1934
+ if (Array.isArray(attributes)) {
1935
+ const attributeNames = attributes
1936
+ .map((entry) => {
1937
+ if (typeof entry === "string") return entry
1938
+ if (!entry || typeof entry !== "object") return null
1939
+
1940
+ const name = /**
1941
+ * Types the following value.
1942
+ * @type {Record<string, ?>} */ (entry).name
1943
+
1944
+ return typeof name === "string" && name.length > 0 ? name : null
1945
+ })
1946
+ .filter((entry) => typeof entry === "string")
1947
+
1948
+ return new Set(attributeNames)
1949
+ }
1950
+
1951
+ if (typeof attributes === "object") {
1952
+ return new Set(Object.keys(attributes))
1953
+ }
1954
+
1955
+ return null
1956
+ }
1957
+
1958
+ /**
1959
+ * Resolves a frontend-supplied key to its canonical model attribute name.
1960
+ * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
1961
+ * @param {string} key - Frontend key or raw column key.
1962
+ * @returns {string | null} - Canonical attribute name.
1963
+ */
1964
+ frontendModelAttributeNameForKey(modelClass, key) {
1965
+ const resolvedAttributeName = modelClass.resolveAttributeName(key)
1966
+
1967
+ if (resolvedAttributeName) return resolvedAttributeName
1968
+
1969
+ const columnAttributeName = modelClass.getColumnNameToAttributeNameMap()[key]
1970
+
1971
+ return columnAttributeName || null
1972
+ }
1973
+
1974
+ /**
1975
+ * Checks if a frontend-supplied attribute is exposed by the resource.
1976
+ * @param {object} args - Args.
1977
+ * @param {string} args.attributeName - Requested attribute name.
1978
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
1979
+ * @returns {boolean} - Whether the resource permits the attribute.
1980
+ */
1981
+ frontendModelAttributeIsExposed({attributeName, modelClass}) {
1982
+ const exposedAttributeNames = this.frontendModelExposedAttributeNamesForModelClass(modelClass)
1983
+
1984
+ if (!exposedAttributeNames) return true
1985
+
1986
+ return exposedAttributeNames.has(attributeName)
1987
+ }
1988
+
1989
+ /**
1990
+ * Validates a selected frontend-model attribute list against resource metadata.
1991
+ * @param {object} args - Args.
1992
+ * @param {string[]} args.attributeNames - Selected attribute names.
1993
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
1994
+ * @param {"select" | "selectsExtra"} args.operationName - Selection operation.
1995
+ * @returns {string[]} - Validated selected attribute names.
1996
+ */
1997
+ validateFrontendModelSelectedAttributes({attributeNames, modelClass, operationName}) {
1998
+ for (const attributeName of attributeNames) {
1999
+ if (this.frontendModelAttributeIsExposed({attributeName, modelClass})) continue
2000
+
2001
+ throw new Error(`Unknown ${operationName} attribute "${attributeName}" for ${modelClass.name}`)
2002
+ }
2003
+
2004
+ return attributeNames
2005
+ }
2006
+
2007
+ /**
2008
+ * Resolves a user-queryable frontend attribute to a database column.
2009
+ * @param {object} args - Args.
2010
+ * @param {string} args.attributeName - Requested attribute name.
2011
+ * @param {typeof import("./database/record/index.js").default} args.modelClass - Model class.
2012
+ * @param {"group" | "pluck" | "search" | "sort" | "where"} args.operationName - Query operation.
2013
+ * @returns {string | undefined} - Resolved column name.
2014
+ */
2015
+ resolveFrontendModelQueryableColumnName({attributeName, modelClass, operationName}) {
2016
+ void operationName
2017
+
2018
+ const resolvedAttributeName = this.frontendModelAttributeNameForKey(modelClass, attributeName)
2019
+
2020
+ if (resolvedAttributeName && !this.frontendModelAttributeIsExposed({attributeName: resolvedAttributeName, modelClass})) {
2021
+ return undefined
2022
+ }
2023
+
2024
+ return this.resolveFrontendModelColumnName(modelClass, attributeName)
2025
+ }
2026
+
1853
2027
  /**
1854
2028
  * Resolves a key that may be either a camelCase attribute name or a raw DB
1855
2029
  * column name to its canonical column name. Returns `undefined` when the
@@ -1880,7 +2054,11 @@ export default class FrontendModelController extends Controller {
1880
2054
  */
1881
2055
  applyFrontendModelWhereForPath({modelClass, path, query, where}) {
1882
2056
  for (const [attributeName, value] of Object.entries(where)) {
1883
- const columnName = this.resolveFrontendModelColumnName(modelClass, attributeName)
2057
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2058
+ attributeName,
2059
+ modelClass,
2060
+ operationName: "where"
2061
+ })
1884
2062
 
1885
2063
  if (columnName) {
1886
2064
  this.ensureFrontendModelJoinPath({path, query})
@@ -2004,7 +2182,11 @@ export default class FrontendModelController extends Controller {
2004
2182
  modelClass,
2005
2183
  path: group.path
2006
2184
  })
2007
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, group.column)
2185
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2186
+ attributeName: group.column,
2187
+ modelClass: targetModelClass,
2188
+ operationName: "group"
2189
+ })
2008
2190
 
2009
2191
  if (!columnName) {
2010
2192
  throw new Error(`Unknown group column "${group.column}" for ${targetModelClass.name}`)
@@ -2117,7 +2299,11 @@ export default class FrontendModelController extends Controller {
2117
2299
  const translatedAttributeNames = Object.keys(translatedAttributesMap)
2118
2300
  const isTranslatedSortAttribute = translatedAttributeNames.includes(sort.column)
2119
2301
 
2120
- const columnName = this.resolveFrontendModelColumnName(targetModelClass, sort.column)
2302
+ const columnName = this.resolveFrontendModelQueryableColumnName({
2303
+ attributeName: sort.column,
2304
+ modelClass: targetModelClass,
2305
+ operationName: "sort"
2306
+ })
2121
2307
  const direction = sort.direction.toUpperCase()
2122
2308
 
2123
2309
  if (isTranslatedSortAttribute) {
@@ -2194,7 +2380,15 @@ export default class FrontendModelController extends Controller {
2194
2380
 
2195
2381
  if (!select) return null
2196
2382
 
2197
- return select[modelClass.getModelName()] || null
2383
+ const selectedAttributes = select[modelClass.getModelName()] || null
2384
+
2385
+ if (!selectedAttributes) return null
2386
+
2387
+ return this.validateFrontendModelSelectedAttributes({
2388
+ attributeNames: selectedAttributes,
2389
+ modelClass,
2390
+ operationName: "select"
2391
+ })
2198
2392
  }
2199
2393
 
2200
2394
  /**
@@ -2207,7 +2401,15 @@ export default class FrontendModelController extends Controller {
2207
2401
 
2208
2402
  if (!selectsExtra) return null
2209
2403
 
2210
- return selectsExtra[modelClass.getModelName()] || null
2404
+ const extraAttributes = selectsExtra[modelClass.getModelName()] || null
2405
+
2406
+ if (!extraAttributes) return null
2407
+
2408
+ return this.validateFrontendModelSelectedAttributes({
2409
+ attributeNames: extraAttributes,
2410
+ modelClass,
2411
+ operationName: "selectsExtra"
2412
+ })
2211
2413
  }
2212
2414
 
2213
2415
  /**
@@ -2274,42 +2476,6 @@ export default class FrontendModelController extends Controller {
2274
2476
  return null
2275
2477
  }
2276
2478
 
2277
- /**
2278
- * Runs frontend model non default attributes for model class.
2279
- * @param {typeof import("./database/record/index.js").default} modelClass - Model class.
2280
- * @returns {string[]} - Attribute names explicitly marked selectedByDefault: false.
2281
- */
2282
- frontendModelNonDefaultAttributesForModelClass(modelClass) {
2283
- const frontendModelResource = this.frontendModelResourceConfigurationForModelClass(modelClass)
2284
- const attributes = frontendModelResource?.resourceConfiguration.attributes
2285
-
2286
- if (!attributes) return []
2287
-
2288
- if (Array.isArray(attributes)) {
2289
- return attributes
2290
- .filter((entry) => {
2291
- if (typeof entry !== "object" || !entry) return false
2292
-
2293
- return /** @type {Record<string, ?>} */ (entry).selectedByDefault === false
2294
- })
2295
- .map((entry) => /**
2296
- * Types the following value.
2297
- * @type {Record<string, ?>} */ (/**
2298
- * Types the following value.
2299
- * @type {?} */ (entry)).name)
2300
- }
2301
-
2302
- if (typeof attributes === "object") {
2303
- return Object.entries(attributes)
2304
- .filter(([, config]) => typeof config === "object" && config && /**
2305
- * Types the following value.
2306
- * @type {Record<string, ?>} */ (config).selectedByDefault === false)
2307
- .map(([name]) => name)
2308
- }
2309
-
2310
- return []
2311
- }
2312
-
2313
2479
  /**
2314
2480
  * Runs serialize frontend model attributes.
2315
2481
  * @param {import("./database/record/index.js").default} model - Model instance.
@@ -2407,12 +2573,10 @@ export default class FrontendModelController extends Controller {
2407
2573
  return modelAttributes
2408
2574
  }
2409
2575
 
2410
- const excludedAttributes = this.frontendModelNonDefaultAttributesForModelClass(modelClass)
2411
- const serializedAttributes = {...modelAttributes}
2412
-
2413
- for (const excludedName of excludedAttributes) {
2414
- delete serializedAttributes[excludedName]
2415
- }
2576
+ /**
2577
+ * Serialized attributes.
2578
+ * @type {Record<string, ?>} */
2579
+ const serializedAttributes = {}
2416
2580
 
2417
2581
  for (const attributeName of defaultAttributes) {
2418
2582
  if (!attributeExists(attributeName)) continue
@@ -2454,7 +2618,7 @@ export default class FrontendModelController extends Controller {
2454
2618
  * @type {typeof import("./database/record/index.js").default} */ (model.constructor).getModelName()
2455
2619
 
2456
2620
  for (const backendProject of backendProjects) {
2457
- const resources = frontendModelResourcesForBackendProject(backendProject)
2621
+ const resources = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)
2458
2622
  const resourceDefinition = resources[modelClassName]
2459
2623
  const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
2460
2624
 
@@ -2775,12 +2939,43 @@ export default class FrontendModelController extends Controller {
2775
2939
  return this.frontendModelErrorPayload(frontendModelClientSafeErrorMessage)
2776
2940
  }
2777
2941
 
2942
+ /**
2943
+ * Builds frontend-model endpoint error context for logging and client payload reporters.
2944
+ * @param {object} args - Error context args.
2945
+ * @param {string} args.action - Endpoint/action label.
2946
+ * @param {unknown} args.error - Caught error.
2947
+ * @param {"index" | "find" | "create" | "update" | "destroy" | "attach" | "download" | "url" | "custom-command"} [args.commandType] - Frontend-model command type.
2948
+ * @param {string | undefined} [args.model] - Request model name when available.
2949
+ * @param {string | undefined} [args.requestId] - Batch request id when available.
2950
+ * @returns {FrontendModelEndpointErrorContext} Frontend-model endpoint error context.
2951
+ */
2952
+ frontendModelEndpointErrorContext({action, commandType, error, model, requestId}) {
2953
+ let resolvedModel = model
2954
+
2955
+ if (!resolvedModel) {
2956
+ const cachedParams = this._frontendModelParamsOverride || this._frontendModelParams
2957
+ const paramsModel = cachedParams ? cachedParams.model : undefined
2958
+ resolvedModel = typeof paramsModel === "string" && paramsModel.length > 0 ? paramsModel : undefined
2959
+ }
2960
+
2961
+ return {
2962
+ action,
2963
+ commandType,
2964
+ controller: this.constructor.name,
2965
+ expectedError: frontendModelExpectedError(error),
2966
+ frontendModelEndpoint: true,
2967
+ model: resolvedModel,
2968
+ requestId
2969
+ }
2970
+ }
2971
+
2778
2972
  /**
2779
2973
  * Runs frontend model client error payload for error.
2780
- * @param {?} error - Caught error.
2781
- * @returns {Promise<Record<string, ?>>} - Client payload for the current environment.
2974
+ * @param {unknown} error - Caught error.
2975
+ * @param {FrontendModelEndpointErrorContext | undefined} [endpointErrorContext] - Frontend-model endpoint error context.
2976
+ * @returns {Promise<import("./configuration-types.js").ClientErrorPayloadReporterPayload>} - Client payload for the current environment.
2782
2977
  */
2783
- async frontendModelClientErrorPayloadForError(error) {
2978
+ async frontendModelClientErrorPayloadForError(error, endpointErrorContext) {
2784
2979
  const velociousMetadata = frontendModelVelociousMetadataForError(error)
2785
2980
  const normalizedError = error instanceof Error ? error : new Error(String(error))
2786
2981
 
@@ -2818,9 +3013,7 @@ export default class FrontendModelController extends Controller {
2818
3013
  ...(velociousMetadata ? {velocious: velociousMetadata} : {}),
2819
3014
  ...validationErrorsPayload,
2820
3015
  ...(await this.getConfiguration().clientErrorPayloadForError({
2821
- context: {
2822
- controller: this.constructor.name
2823
- },
3016
+ context: endpointErrorContext || {controller: this.constructor.name},
2824
3017
  error: normalizedError,
2825
3018
  request: this.getRequest()
2826
3019
  }))
@@ -2838,23 +3031,12 @@ export default class FrontendModelController extends Controller {
2838
3031
  * @returns {Promise<void>} - Resolves after logging.
2839
3032
  */
2840
3033
  async frontendModelLogEndpointError({action, error, commandType, model, requestId}) {
2841
- // Errors annotated with `error.velocious = {...}` are user-flow
2842
- // failures the developer has marked as expected (bad password,
2843
- // validation message, etc.). Surface the message + metadata to
2844
- // the client (handled by frontendModelClientErrorPayloadForError),
2845
- // but skip the error log so monitoring stays focused on real
2846
- // backend failures.
2847
- if (frontendModelErrorHasVelociousMetadata(error)) return
3034
+ const errorContext = this.frontendModelEndpointErrorContext({action, commandType, error, model, requestId})
2848
3035
 
2849
- let resolvedModel = model
2850
-
2851
- if (!resolvedModel) {
2852
- try {
2853
- resolvedModel = this.frontendModelParams().model
2854
- } catch {
2855
- resolvedModel = undefined
2856
- }
2857
- }
3036
+ // Expected user-flow errors are surfaced to clients by
3037
+ // frontendModelClientErrorPayloadForError, but skipped here so monitoring
3038
+ // stays focused on real backend failures.
3039
+ if (errorContext.expectedError) return
2858
3040
 
2859
3041
  const errorMessage = error instanceof Error
2860
3042
  ? `${error.message}\n${error.stack || ""}`
@@ -2864,27 +3046,22 @@ export default class FrontendModelController extends Controller {
2864
3046
  action,
2865
3047
  commandType,
2866
3048
  error: errorMessage,
2867
- model: resolvedModel,
3049
+ model: errorContext.model,
2868
3050
  requestId
2869
3051
  }])
2870
3052
 
2871
3053
  // Surface genuinely unexpected backend failures on the framework-error
2872
3054
  // channel so process-level bug reporters capture them, instead of the
2873
3055
  // controller silently swallowing them behind the generic "Request
2874
- // failed." client message. Developer-annotated user-flow errors
2875
- // (`error.velocious` metadata — handled by the early return above),
2876
- // validation errors, and deliberately client-safe VelociousErrors are
2877
- // expected and must NOT be reported as framework errors.
2878
- if (!(error instanceof ValidationError) && !(error instanceof VelociousError && error.safeToExpose)) {
2879
- const errorPayload = {
2880
- context: {action, commandType, frontendModelEndpoint: true, model: resolvedModel, requestId},
2881
- error: error instanceof Error ? error : new Error(String(error)),
2882
- request: this.getRequest()
2883
- }
2884
-
2885
- this.getConfiguration().getErrorEvents().emit("framework-error", errorPayload)
2886
- this.getConfiguration().getErrorEvents().emit("all-error", {...errorPayload, errorType: "framework-error"})
3056
+ // failed." client message.
3057
+ const errorPayload = {
3058
+ context: errorContext,
3059
+ error: error instanceof Error ? error : new Error(String(error)),
3060
+ request: this.getRequest()
2887
3061
  }
3062
+
3063
+ this.getConfiguration().getErrorEvents().emit("framework-error", errorPayload)
3064
+ this.getConfiguration().getErrorEvents().emit("all-error", {...errorPayload, errorType: "framework-error"})
2888
3065
  }
2889
3066
 
2890
3067
  /**
@@ -2903,12 +3080,14 @@ export default class FrontendModelController extends Controller {
2903
3080
  * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload))
2904
3081
  })
2905
3082
  } catch (error) {
2906
- await this.frontendModelLogEndpointError({action, commandType: action, error})
3083
+ const errorContext = this.frontendModelEndpointErrorContext({action, commandType: action, error})
3084
+
3085
+ await this.frontendModelLogEndpointError({action, commandType: action, error, model: errorContext.model})
2907
3086
 
2908
3087
  await this.render({
2909
3088
  json: /**
2910
3089
  * Types the following value.
2911
- * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error)))
3090
+ * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext)))
2912
3091
  })
2913
3092
  }
2914
3093
  }
@@ -3183,7 +3362,7 @@ export default class FrontendModelController extends Controller {
3183
3362
  response: responsePayload || this.frontendModelErrorPayload("Action halted by beforeAction.")
3184
3363
  })
3185
3364
  } catch (error) {
3186
- await this.frontendModelLogEndpointError({
3365
+ const errorContext = this.frontendModelEndpointErrorContext({
3187
3366
  action: "frontendApi",
3188
3367
  commandType,
3189
3368
  error,
@@ -3191,9 +3370,17 @@ export default class FrontendModelController extends Controller {
3191
3370
  requestId
3192
3371
  })
3193
3372
 
3373
+ await this.frontendModelLogEndpointError({
3374
+ action: errorContext.action,
3375
+ commandType: errorContext.commandType,
3376
+ error,
3377
+ model: errorContext.model,
3378
+ requestId: errorContext.requestId
3379
+ })
3380
+
3194
3381
  responses.push({
3195
3382
  requestId,
3196
- response: await this.frontendModelClientErrorPayloadForError(error)
3383
+ response: await this.frontendModelClientErrorPayloadForError(error, errorContext)
3197
3384
  })
3198
3385
  }
3199
3386
  }
@@ -3414,12 +3601,14 @@ export default class FrontendModelController extends Controller {
3414
3601
  * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(responsePayload))
3415
3602
  })
3416
3603
  } catch (error) {
3417
- await this.frontendModelLogEndpointError({action: "frontendCustomCommand", commandType: "custom-command", error})
3604
+ const errorContext = this.frontendModelEndpointErrorContext({action: "frontendCustomCommand", commandType: "custom-command", error})
3605
+
3606
+ await this.frontendModelLogEndpointError({action: errorContext.action, commandType: errorContext.commandType, error, model: errorContext.model})
3418
3607
 
3419
3608
  await this.render({
3420
3609
  json: /**
3421
3610
  * Types the following value.
3422
- * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error)))
3611
+ * @type {Record<string, ?>} */ (serializeFrontendModelTransportValue(await this.frontendModelClientErrorPayloadForError(error, errorContext)))
3423
3612
  })
3424
3613
  }
3425
3614
  }
@@ -14,6 +14,7 @@ import isPlainObject from "../utils/plain-object.js"
14
14
  * @typedef {import("../controller.js").default & {
15
15
  * currentAbility: () => import("../authorization/ability.js").default | undefined,
16
16
  * frontendModelAbilityAction: (action: FrontendModelResourceAction) => string,
17
+ * frontendModelAbilityAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
17
18
  * frontendModelAuthorizedQuery: (action: FrontendModelResourceAction) => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
18
19
  * frontendModelIndexQuery: () => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>,
19
20
  * frontendModelParams: () => import("../configuration-types.js").VelociousParams,
@@ -393,7 +394,7 @@ export default class FrontendModelBaseResource extends AuthorizationBaseResource
393
394
  */
394
395
  authorizedQuery(action) {
395
396
  // Narrows the controller query to this resource's model class.
396
- return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAuthorizedQuery(action))
397
+ return /** @type {import("../database/query/model-class-query.js").default<TModelClass>} */ (this.typedControllerInstance().frontendModelAbilityAuthorizedQuery(action))
397
398
  }
398
399
 
399
400