velocious 1.0.450 → 1.0.451

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.
@@ -11,18 +11,26 @@ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigura
11
11
  * @property {string} [columnType] - Column type.
12
12
  * @property {string} [sqlType] - SQL type.
13
13
  * @property {string} [dataType] - Data type.
14
+ * @property {string} [jsDocType] - Exact JSDoc type.
14
15
  * @property {string} [name] - Attribute name when configured as an array entry.
15
16
  * @property {boolean} [null] - Whether null is allowed.
17
+ * @property {boolean} [selectedByDefault] - Whether the attribute is selected by default.
16
18
  * @property {() => string} [getType] - Returns column type.
17
19
  * @property {() => boolean} [getNull] - Returns whether null is allowed.
18
20
  */
19
21
  /**
20
22
  * Permit spec returned by frontend-model resources during generation.
21
- * @typedef {Array<string | Record<string, object>>} FrontendModelGeneratorPermitSpec
23
+ * @typedef {Array<string | Record<string, FrontendModelGeneratorPermitSpec>>} FrontendModelGeneratorPermitSpec
22
24
  */
23
25
 
24
26
  /** Node CLI command that generates frontend model classes from backend project resource config. */
25
27
  export default class DbGenerateFrontendModels extends BaseCommand {
28
+ /** @type {Map<string, string> | null} */
29
+ _resourceMethodReturnTypes = null
30
+
31
+ /** @type {Map<string, string[]> | null} */
32
+ _resourceMethodParameterTypes = null
33
+
26
34
  /**
27
35
  * Runs execute.
28
36
  * @returns {Promise<void>} - Resolves when files are generated.
@@ -97,7 +105,9 @@ export default class DbGenerateFrontendModels extends BaseCommand {
97
105
  throw new Error(`Invalid frontend model resource definition for '${className}'`)
98
106
  }
99
107
 
100
- this.validateModelConfig({availableFrontendModelClassNames, className, modelConfig, resourceClass: frontendModelResourceClassFromDefinition(resources[modelClassName])})
108
+ const resourceClass = frontendModelResourceClassFromDefinition(resources[modelClassName])
109
+
110
+ this.validateModelConfig({availableFrontendModelClassNames, className, modelConfig, resourceClass})
101
111
 
102
112
  if (generatedModelNames.has(className)) {
103
113
  throw new Error(`Duplicate frontend model definition for '${className}'`)
@@ -105,12 +115,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
105
115
 
106
116
  generatedModelNames.add(className)
107
117
 
108
- const fileContent = this.buildModelFileContent({
118
+ const fileContent = await this.buildModelFileContent({
109
119
  className,
110
120
  importPath,
111
- modelClass: configuration.getModelClasses()[className],
121
+ modelClass: resourceClass?.ModelClass || configuration.getModelClasses()[className],
112
122
  modelConfig,
113
- resourceClass: frontendModelResourceClassFromDefinition(resources[modelClassName])
123
+ resourceClass
114
124
  })
115
125
 
116
126
  await fs.writeFile(filePath, fileContent)
@@ -236,10 +246,10 @@ export default class DbGenerateFrontendModels extends BaseCommand {
236
246
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
237
247
  * @param {import("../../../../../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.modelConfig - Model configuration.
238
248
  * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null} [args.resourceClass] - Resource class.
239
- * @returns {string} - Generated file content.
249
+ * @returns {Promise<string>} - Generated file content.
240
250
  */
241
- buildModelFileContent({className, importPath, modelClass, modelConfig, resourceClass}) {
242
- const attributes = this.attributeDefinitionsForModel({modelClass, modelConfig})
251
+ async buildModelFileContent({className, importPath, modelClass, modelConfig, resourceClass}) {
252
+ const attributes = await this.attributeDefinitionsForModel({className, modelClass, modelConfig, resourceClass})
243
253
  const relationships = this.relationshipsForModel({className, modelConfig, resourceClass})
244
254
  const attachments = modelConfig.attachments && typeof modelConfig.attachments === "object"
245
255
  ? modelConfig.attachments
@@ -311,8 +321,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
311
321
  }
312
322
  fileContent += " */\n"
313
323
  }
314
- fileContent += this.writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams: permittedCreateParams, typeName: createAttributesTypeName})
315
- fileContent += this.writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams: permittedUpdateParams, typeName: updateAttributesTypeName})
324
+ fileContent += await this.writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams: permittedCreateParams, resourceClass, typeName: createAttributesTypeName})
325
+ fileContent += await this.writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams: permittedUpdateParams, resourceClass, typeName: updateAttributesTypeName})
316
326
  fileContent += "/**\n"
317
327
  fileContent += ` * Frontend model for ${className}.\n`
318
328
  fileContent += ` * @augments {FrontendModelBase<${attributesTypeName}, ${createAttributesTypeName}, ${updateAttributesTypeName}>}\n`
@@ -418,17 +428,24 @@ export default class DbGenerateFrontendModels extends BaseCommand {
418
428
  for (const attribute of attributes) {
419
429
  const camelizedAttribute = inflection.camelize(attribute.name, true)
420
430
  const camelizedAttributeUpper = inflection.camelize(attribute.name)
431
+ const attributeType = `${attributesTypeName}[${JSON.stringify(attribute.name)}]`
432
+ const setterAttributeType = await this.frontendWriteAttributeType({
433
+ attribute,
434
+ attributeName: attribute.name,
435
+ attributesTypeName,
436
+ resourceClass
437
+ })
421
438
 
422
439
  fileContent += "\n"
423
- fileContent += ` /** @returns {${attributesTypeName}[${JSON.stringify(attribute.name)}]} - Attribute value. */\n`
424
- fileContent += ` ${camelizedAttribute}() { return /** @type {${attributesTypeName}[${JSON.stringify(attribute.name)}]} */ (this.readAttribute(${JSON.stringify(attribute.name)})) }\n`
440
+ fileContent += ` /** @returns {${attributeType}} - Attribute value. */\n`
441
+ fileContent += ` ${camelizedAttribute}() { return /** @type {${attributeType}} */ (this.readAttribute(${JSON.stringify(attribute.name)})) }\n`
425
442
 
426
443
  fileContent += "\n"
427
444
  fileContent += " /**\n"
428
- fileContent += ` * @param {${attributesTypeName}[${JSON.stringify(attribute.name)}]} newValue - New attribute value.\n`
429
- fileContent += ` * @returns {${attributesTypeName}[${JSON.stringify(attribute.name)}]} - Assigned value.\n`
445
+ fileContent += ` * @param {${setterAttributeType}} newValue - New attribute value.\n`
446
+ fileContent += ` * @returns {${setterAttributeType}} - Assigned value.\n`
430
447
  fileContent += " */\n"
431
- fileContent += ` set${camelizedAttributeUpper}(newValue) { return /** @type {${attributesTypeName}[${JSON.stringify(attribute.name)}]} */ (this.setAttribute(${JSON.stringify(attribute.name)}, newValue)) }\n`
448
+ fileContent += ` set${camelizedAttributeUpper}(newValue) { return /** @type {${setterAttributeType}} */ (this.setAttribute(${JSON.stringify(attribute.name)}, newValue)) }\n`
432
449
  }
433
450
 
434
451
  for (const methodName of Object.keys(collectionCommands)) {
@@ -597,11 +614,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
597
614
  * @param {string} args.attributesTypeName - Generated read attributes typedef name.
598
615
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
599
616
  * @param {Array<{attributes: Array<{name: string, type: string}>, relationshipName: string, typeName: string}>} args.nestedWriteTypes - Nested write typedefs.
600
- * @param {Array<string | Record<string, object>>} args.permittedParams - Resource permitted params spec.
617
+ * @param {FrontendModelGeneratorPermitSpec} args.permittedParams - Resource permitted params spec.
618
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
601
619
  * @param {string} args.typeName - Typedef name.
602
- * @returns {string} - Generated typedef source.
620
+ * @returns {Promise<string>} - Generated typedef source.
603
621
  */
604
- writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams, typeName}) {
622
+ async writeAttributesTypedef({attributes, attributesTypeName, modelClass, nestedWriteTypes, permittedParams, resourceClass, typeName}) {
605
623
  const attributeLines = []
606
624
 
607
625
  let output = "/**\n"
@@ -618,8 +636,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
618
636
 
619
637
  emittedAttributeNames.add(attributeName)
620
638
 
621
- const attribute = attributesByName.get(attributeName)
622
- const type = attribute ? `${attributesTypeName}[${JSON.stringify(attribute.name)}]` : "FrontendModelAttributeValue"
639
+ const type = await this.frontendWriteAttributeType({
640
+ attribute: attributesByName.get(attributeName),
641
+ attributeName,
642
+ attributesTypeName,
643
+ resourceClass
644
+ })
623
645
 
624
646
  attributeLines.push(` * @property {${type}} [${attributeName}] - Permitted ${attributeName} value.\n`)
625
647
  } else if (entry && typeof entry == "object" && !Array.isArray(entry)) {
@@ -644,6 +666,58 @@ export default class DbGenerateFrontendModels extends BaseCommand {
644
666
  return output
645
667
  }
646
668
 
669
+ /**
670
+ * Runs frontend write attribute type.
671
+ * @param {{attribute: {jsDocType: string, name: string} | undefined, attributeName: string, attributesTypeName: string, resourceClass: import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined}} args - Arguments.
672
+ * @returns {Promise<string>} - JSDoc type for the permitted write field.
673
+ */
674
+ async frontendWriteAttributeType({attribute, attributeName, attributesTypeName, resourceClass}) {
675
+ const setterParameterType = await this.frontendWriteAttributeSetterParameterType({attributeName, resourceClass})
676
+
677
+ if (setterParameterType) return `${setterParameterType} | null`
678
+
679
+ if (!attribute) return "FrontendModelAttributeValue"
680
+
681
+ if (attribute.jsDocType.trim() === "null") return "FrontendModelAttributeValue"
682
+
683
+ return `${attributesTypeName}[${JSON.stringify(attribute.name)}] | null`
684
+ }
685
+
686
+ /**
687
+ * Runs frontend write attribute setter parameter type.
688
+ * @param {{attributeName: string, resourceClass: import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined}} args - Arguments.
689
+ * @returns {Promise<string | null>} - Setter value parameter type when it is useful for generation.
690
+ */
691
+ async frontendWriteAttributeSetterParameterType({attributeName, resourceClass}) {
692
+ if (!resourceClass?.name) return null
693
+
694
+ const methodName = `set${inflection.camelize(attributeName)}Attribute`
695
+ const parameterType = await this.resourceMethodParameterType({
696
+ methodName,
697
+ parameterIndex: 1,
698
+ sourceClassName: resourceClass.name
699
+ })
700
+
701
+ if (!parameterType) return null
702
+ if (this.isBroadGeneratedType(parameterType)) return null
703
+
704
+ return parameterType
705
+ }
706
+
707
+ /**
708
+ * Runs is broad generated type.
709
+ * @param {string} jsDocType - JSDoc type.
710
+ * @returns {boolean} - Whether the type is too broad to improve generated write typing.
711
+ */
712
+ isBroadGeneratedType(jsDocType) {
713
+ const normalizedType = jsDocType.trim()
714
+
715
+ return normalizedType === "?"
716
+ || normalizedType === "any"
717
+ || normalizedType === "object"
718
+ || normalizedType === "unknown"
719
+ }
720
+
647
721
  /**
648
722
  * Resolves a permitted write attribute to the generated frontend attribute name.
649
723
  * @param {{attributeName: string, attributesByName: Map<string, {jsDocType: string, name: string}>, modelClass: typeof import("../../../../../database/record/index.js").default | undefined}} args - Arguments.
@@ -894,64 +968,172 @@ export default class DbGenerateFrontendModels extends BaseCommand {
894
968
  /**
895
969
  * Runs attribute definitions for model.
896
970
  * @param {object} args - Arguments.
971
+ * @param {string} args.className - Frontend model class name.
897
972
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
898
973
  * @param {import("../../../../../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.modelConfig - Model configuration.
899
- * @returns {Array<{jsDocType: string, name: string}>} - Attribute definitions.
974
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null} [args.resourceClass] - Resource class.
975
+ * @returns {Promise<Array<{jsDocType: string, name: string}>>} - Attribute definitions.
900
976
  */
901
- attributeDefinitionsForModel({modelClass, modelConfig}) {
977
+ async attributeDefinitionsForModel({className, modelClass, modelConfig, resourceClass}) {
902
978
  let attributes = modelConfig.attributes
903
979
 
904
980
  // Auto-derive attributes from model columns when not explicitly defined
905
981
  if ((!attributes || (Array.isArray(attributes) && attributes.length === 0)) && modelClass) {
906
- try {
907
- const columns = modelClass.getColumns()
982
+ const columns = modelClass.getColumns()
908
983
 
909
- if (Array.isArray(columns)) {
910
- attributes = columns.map((column) => inflection.camelize(column.getName(), true))
911
- }
912
- } catch {
913
- // Model may not be initialized yet
984
+ if (Array.isArray(columns)) {
985
+ attributes = columns.map((column) => inflection.camelize(column.getName(), true))
914
986
  }
915
987
  }
916
988
 
917
989
  if (Array.isArray(attributes)) {
918
- return attributes.map((attributeDefinition) => {
990
+ const attributeDefinitions = []
991
+
992
+ for (const attributeDefinition of attributes) {
919
993
  /** @type {FrontendAttributeConfig | null} */
920
- let attributeConfig = null
994
+ let configuredAttributeConfig = null
921
995
  let attributeName
922
996
 
923
997
  if (typeof attributeDefinition == "string") {
924
998
  attributeName = attributeDefinition
925
- attributeConfig = this.frontendAttributeConfigForModelAttribute({attributeName, modelClass})
926
999
  } else if (attributeDefinition && typeof attributeDefinition == "object" && !Array.isArray(attributeDefinition)) {
927
- attributeConfig = /** @type {FrontendAttributeConfig} */ (attributeDefinition)
928
- attributeName = attributeConfig.name
1000
+ configuredAttributeConfig = /** @type {FrontendAttributeConfig} */ (attributeDefinition)
1001
+ attributeName = configuredAttributeConfig.name
929
1002
  }
930
1003
 
931
1004
  if (typeof attributeName != "string" || attributeName.length < 1) {
932
1005
  throw new Error(`Expected frontend model attribute array entries to be strings or objects with a name, got: ${JSON.stringify(attributeDefinition)}`)
933
1006
  }
934
1007
 
935
- return {
936
- jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig}),
1008
+ const attributeConfig = await this.resolvedFrontendAttributeConfig({
1009
+ attributeName,
1010
+ className,
1011
+ configuredAttributeConfig,
1012
+ modelClass,
1013
+ resourceClass
1014
+ })
1015
+
1016
+ const frontendAttributeConfig = this.frontendAttributeConfigForGeneratedAttribute({
1017
+ attributeConfig,
1018
+ attributeName,
1019
+ modelClass
1020
+ })
1021
+
1022
+ attributeDefinitions.push({
1023
+ jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig: frontendAttributeConfig}),
937
1024
  name: attributeName
938
- }
939
- })
1025
+ })
1026
+ }
1027
+
1028
+ return attributeDefinitions
940
1029
  }
941
1030
 
942
1031
  if (!attributes || typeof attributes !== "object") {
943
1032
  throw new Error(`Expected 'attributes' as array or object but got: ${attributes}`)
944
1033
  }
945
1034
 
946
- return Object.keys(attributes).map((attributeName) => {
1035
+ const attributeDefinitions = []
1036
+
1037
+ for (const attributeName of Object.keys(attributes)) {
947
1038
  const attributeConfig = attributes[attributeName]
948
- const normalizedAttributeConfig = attributeConfig && typeof attributeConfig === "object" ? attributeConfig : null
1039
+ const configuredAttributeConfig = attributeConfig && typeof attributeConfig === "object"
1040
+ ? /** @type {FrontendAttributeConfig} */ (attributeConfig)
1041
+ : null
1042
+ const normalizedAttributeConfig = await this.resolvedFrontendAttributeConfig({
1043
+ attributeName,
1044
+ className,
1045
+ configuredAttributeConfig,
1046
+ modelClass,
1047
+ resourceClass
1048
+ })
1049
+ const frontendAttributeConfig = this.frontendAttributeConfigForGeneratedAttribute({
1050
+ attributeConfig: normalizedAttributeConfig,
1051
+ attributeName,
1052
+ modelClass
1053
+ })
949
1054
 
950
- return {
951
- jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig: normalizedAttributeConfig}),
1055
+ attributeDefinitions.push({
1056
+ jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig: frontendAttributeConfig}),
952
1057
  name: attributeName
953
- }
954
- })
1058
+ })
1059
+ }
1060
+
1061
+ return attributeDefinitions
1062
+ }
1063
+
1064
+ /**
1065
+ * Runs frontend attribute config for generated attribute.
1066
+ * @param {{attributeConfig: FrontendAttributeConfig, attributeName: string, modelClass: typeof import("../../../../../database/record/index.js").default | undefined}} args - Arguments.
1067
+ * @returns {FrontendAttributeConfig} - Attribute config used for generated JSDoc.
1068
+ */
1069
+ frontendAttributeConfigForGeneratedAttribute({attributeConfig, attributeName, modelClass}) {
1070
+ if (!this.frontendAttributeIsModelPrimaryKey({attributeName, modelClass})) return attributeConfig
1071
+
1072
+ return {...attributeConfig, null: false}
1073
+ }
1074
+
1075
+ /**
1076
+ * Runs frontend attribute is model primary key.
1077
+ * @param {{attributeName: string, modelClass: typeof import("../../../../../database/record/index.js").default | undefined}} args - Arguments.
1078
+ * @returns {boolean} - Whether the attribute is the model primary key.
1079
+ */
1080
+ frontendAttributeIsModelPrimaryKey({attributeName, modelClass}) {
1081
+ if (!modelClass) return false
1082
+
1083
+ const primaryKey = modelClass.primaryKey()
1084
+
1085
+ if (typeof primaryKey != "string" || primaryKey.length < 1) return false
1086
+ if (attributeName === primaryKey) return true
1087
+
1088
+ return modelClass.resolveAttributeName(primaryKey) === attributeName
1089
+ }
1090
+
1091
+ /**
1092
+ * Resolves frontend attribute config from explicit metadata, resource methods, model columns, translated columns, or model accessor JSDoc.
1093
+ * @param {object} args - Arguments.
1094
+ * @param {string} args.attributeName - Frontend attribute name.
1095
+ * @param {string} args.className - Frontend model class name.
1096
+ * @param {FrontendAttributeConfig | null} args.configuredAttributeConfig - Resource-provided attribute config.
1097
+ * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
1098
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
1099
+ * @returns {Promise<FrontendAttributeConfig>} - Resolved frontend attribute config.
1100
+ */
1101
+ async resolvedFrontendAttributeConfig({attributeName, className, configuredAttributeConfig, modelClass, resourceClass}) {
1102
+ const inferredResourceConfig = await this.frontendAttributeConfigForResourceAttribute({attributeName, resourceClass})
1103
+ const inferredColumnConfig = inferredResourceConfig
1104
+ ? null
1105
+ : this.frontendAttributeConfigForModelAttribute({attributeName, modelClass})
1106
+ const inferredTranslatedConfig = inferredResourceConfig || inferredColumnConfig
1107
+ ? null
1108
+ : this.frontendAttributeConfigForTranslatedAttribute({attributeName, modelClass, resourceClass})
1109
+ const inferredModelAccessorConfig = inferredResourceConfig || inferredColumnConfig || inferredTranslatedConfig
1110
+ ? null
1111
+ : await this.frontendAttributeConfigForModelAccessor({attributeName, modelClass})
1112
+ const inferredConfig = inferredResourceConfig || inferredColumnConfig || inferredTranslatedConfig || inferredModelAccessorConfig
1113
+
1114
+ if (configuredAttributeConfig && this.frontendAttributeConfigHasType(configuredAttributeConfig)) {
1115
+ return inferredConfig
1116
+ ? {...inferredConfig, ...configuredAttributeConfig}
1117
+ : configuredAttributeConfig
1118
+ }
1119
+
1120
+ if (inferredConfig) {
1121
+ return configuredAttributeConfig
1122
+ ? {...inferredConfig, ...configuredAttributeConfig}
1123
+ : inferredConfig
1124
+ }
1125
+
1126
+ throw new Error(`Could not infer JSDoc type for frontend model attribute '${className}#${attributeName}'. Add a backend model column, translation table column, explicit resource metadata, or a @returns JSDoc type on ${resourceClass?.name || "the resource"}.${attributeName}Attribute().`)
1127
+ }
1128
+
1129
+ /**
1130
+ * Runs frontend attribute config has type.
1131
+ * @param {FrontendAttributeConfig | null | undefined} attributeConfig - Attribute config.
1132
+ * @returns {boolean} - Whether the config declares a type source.
1133
+ */
1134
+ frontendAttributeConfigHasType(attributeConfig) {
1135
+ return typeof this.frontendAttributeTypeValue(attributeConfig) == "string"
1136
+ || typeof attributeConfig?.jsDocType == "string"
955
1137
  }
956
1138
 
957
1139
  /**
@@ -961,6 +1143,10 @@ export default class DbGenerateFrontendModels extends BaseCommand {
961
1143
  * @returns {string} - JSDoc type.
962
1144
  */
963
1145
  jsDocTypeForFrontendAttribute({attributeConfig}) {
1146
+ if (attributeConfig && typeof attributeConfig.jsDocType == "string" && attributeConfig.jsDocType.length > 0) {
1147
+ return attributeConfig.jsDocType
1148
+ }
1149
+
964
1150
  const jsDocType = this.jsDocTypeForFrontendAttributeBaseType(attributeConfig)
965
1151
 
966
1152
  if (!this.frontendAttributeCanBeNull(attributeConfig)) {
@@ -986,7 +1172,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
986
1172
  return "boolean"
987
1173
  } else if (type == "json" || type == "jsonb") {
988
1174
  return "FrontendModelTransportValue"
989
- } else if (type && ["blob", "char", "nvarchar", "varchar", "text", "longtext", "uuid", "character varying"].includes(type)) {
1175
+ } else if (type && ["blob", "char", "nvarchar", "varchar", "text", "longtext", "mediumtext", "tinytext", "uuid", "character varying"].includes(type)) {
990
1176
  return "string"
991
1177
  } else if (type && ["bit", "bigint", "decimal", "double", "double precision", "float", "int", "integer", "numeric", "real", "smallint", "tinyint"].includes(type)) {
992
1178
  return "number"
@@ -1037,6 +1223,517 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1037
1223
  return typeValue
1038
1224
  }
1039
1225
 
1226
+ /**
1227
+ * Runs frontend attribute config for resource attribute.
1228
+ * @param {object} args - Arguments.
1229
+ * @param {string} args.attributeName - Frontend model attribute name.
1230
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
1231
+ * @returns {Promise<FrontendAttributeConfig | null>} - Attribute config inferred from resource method JSDoc.
1232
+ */
1233
+ async frontendAttributeConfigForResourceAttribute({attributeName, resourceClass}) {
1234
+ if (!resourceClass) return null
1235
+
1236
+ const methodName = `${attributeName}Attribute`
1237
+ const ownerClassName = this.methodOwnerClassName({methodName, targetClass: resourceClass})
1238
+
1239
+ if (!ownerClassName) return null
1240
+
1241
+ const jsDocType = await this.resourceMethodReturnType({
1242
+ methodName,
1243
+ sourceClassName: ownerClassName
1244
+ })
1245
+
1246
+ return jsDocType ? {jsDocType: this.unwrappedPromiseJsDocType({jsDocType})} : null
1247
+ }
1248
+
1249
+ /**
1250
+ * Runs frontend attribute config for translated attribute.
1251
+ * @param {object} args - Arguments.
1252
+ * @param {string} args.attributeName - Frontend model attribute name.
1253
+ * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
1254
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
1255
+ * @returns {FrontendAttributeConfig | null} - Attribute config inferred from translated attribute columns.
1256
+ */
1257
+ frontendAttributeConfigForTranslatedAttribute({attributeName, modelClass, resourceClass}) {
1258
+ if (!modelClass) return null
1259
+ if (!this.frontendAttributeIsTranslated({attributeName, modelClass, resourceClass})) return null
1260
+
1261
+ const TranslationClass = modelClass.getTranslationClass()
1262
+ const columnName = inflection.underscore(attributeName)
1263
+
1264
+ let column
1265
+
1266
+ try {
1267
+ column = TranslationClass.getColumnsHash()[columnName]
1268
+ } catch (error) {
1269
+ if (error instanceof Error && (error.message.includes("hasn't been initialized yet") || error.message.includes("used before initialization"))) return null
1270
+
1271
+ throw error
1272
+ }
1273
+
1274
+ return column ? this.frontendAttributeConfigForColumn({column}) : null
1275
+ }
1276
+
1277
+ /**
1278
+ * Runs frontend attribute is translated.
1279
+ * @param {object} args - Arguments.
1280
+ * @param {string} args.attributeName - Frontend model attribute name.
1281
+ * @param {typeof import("../../../../../database/record/index.js").default} args.modelClass - Backend model class.
1282
+ * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
1283
+ * @returns {boolean} - Whether the frontend attribute is translated.
1284
+ */
1285
+ frontendAttributeIsTranslated({attributeName, modelClass, resourceClass}) {
1286
+ if (resourceClass) {
1287
+ const translatedAttributes = resourceClass.translatedAttributes
1288
+
1289
+ if (Array.isArray(translatedAttributes) && translatedAttributes.includes(attributeName)) return true
1290
+ }
1291
+
1292
+ const translations = modelClass._translations
1293
+
1294
+ return Boolean(translations && typeof translations == "object" && Object.prototype.hasOwnProperty.call(translations, attributeName))
1295
+ }
1296
+
1297
+ /**
1298
+ * Runs frontend attribute config for model accessor.
1299
+ * @param {object} args - Arguments.
1300
+ * @param {string} args.attributeName - Frontend model attribute name.
1301
+ * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
1302
+ * @returns {Promise<FrontendAttributeConfig | null>} - Attribute config inferred from model accessor JSDoc.
1303
+ */
1304
+ async frontendAttributeConfigForModelAccessor({attributeName, modelClass}) {
1305
+ if (!modelClass) return null
1306
+
1307
+ const ownerClassName = this.methodOwnerClassName({methodName: attributeName, targetClass: modelClass})
1308
+
1309
+ if (!ownerClassName) return null
1310
+
1311
+ const jsDocType = await this.resourceMethodReturnType({
1312
+ methodName: attributeName,
1313
+ sourceClassName: ownerClassName
1314
+ })
1315
+
1316
+ return jsDocType ? {jsDocType} : null
1317
+ }
1318
+
1319
+ /**
1320
+ * Runs unwrapped promise js doc type.
1321
+ * @param {object} args - Arguments.
1322
+ * @param {string} args.jsDocType - JSDoc type to normalize.
1323
+ * @returns {string} - The resolved value type for serialized frontend attributes.
1324
+ */
1325
+ unwrappedPromiseJsDocType({jsDocType}) {
1326
+ const promisePrefix = "Promise<"
1327
+
1328
+ if (!jsDocType.startsWith(promisePrefix)) return jsDocType
1329
+
1330
+ if (!jsDocType.endsWith(">")) {
1331
+ throw new Error(`Expected Promise JSDoc type to end with '>': ${jsDocType}`)
1332
+ }
1333
+
1334
+ const resolvedType = jsDocType.slice(promisePrefix.length, -1).trim()
1335
+
1336
+ if (resolvedType.length < 1) {
1337
+ throw new Error(`Expected Promise JSDoc type to contain a resolved type: ${jsDocType}`)
1338
+ }
1339
+
1340
+ return resolvedType
1341
+ }
1342
+
1343
+ /**
1344
+ * Runs method owner class name.
1345
+ * @param {object} args - Arguments.
1346
+ * @param {string} args.methodName - Method name.
1347
+ * @param {typeof import("../../../../../database/record/index.js").default | import("../../../../../configuration-types.js").FrontendModelResourceClassType} args.targetClass - Target class.
1348
+ * @returns {string | null} - Class name that declares the method.
1349
+ */
1350
+ methodOwnerClassName({methodName, targetClass}) {
1351
+ let prototype = targetClass.prototype
1352
+
1353
+ while (prototype && prototype !== Object.prototype) {
1354
+ if (Object.prototype.hasOwnProperty.call(prototype, methodName)) {
1355
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName)
1356
+
1357
+ if (typeof descriptor?.value != "function") return null
1358
+
1359
+ const constructorName = prototype.constructor?.name
1360
+
1361
+ if (typeof constructorName == "string" && constructorName.length > 0) return constructorName
1362
+
1363
+ return null
1364
+ }
1365
+
1366
+ prototype = Object.getPrototypeOf(prototype)
1367
+ }
1368
+
1369
+ return null
1370
+ }
1371
+
1372
+ /**
1373
+ * Runs resource method return type.
1374
+ * @param {object} args - Arguments.
1375
+ * @param {string} args.methodName - Method name.
1376
+ * @param {string} args.sourceClassName - Source class name.
1377
+ * @returns {Promise<string | null>} - JSDoc return type when documented.
1378
+ */
1379
+ async resourceMethodReturnType({methodName, sourceClassName}) {
1380
+ const resourceMethodReturnTypes = await this.resourceMethodReturnTypes()
1381
+ const returnTypeKey = `${sourceClassName}.${methodName}`
1382
+
1383
+ if (!resourceMethodReturnTypes.has(returnTypeKey)) return null
1384
+
1385
+ const returnType = resourceMethodReturnTypes.get(returnTypeKey)
1386
+
1387
+ if (typeof returnType != "string" || returnType.length < 1) {
1388
+ throw new Error(`Expected non-empty JSDoc return type for ${returnTypeKey}`)
1389
+ }
1390
+
1391
+ return returnType
1392
+ }
1393
+
1394
+ /**
1395
+ * Runs resource method parameter type.
1396
+ * @param {{methodName: string, parameterIndex: number, sourceClassName: string}} args - Arguments.
1397
+ * @returns {Promise<string | null>} - JSDoc parameter type when documented.
1398
+ */
1399
+ async resourceMethodParameterType({methodName, parameterIndex, sourceClassName}) {
1400
+ const resourceMethodParameterTypes = await this.resourceMethodParameterTypes()
1401
+ const parameterTypesKey = `${sourceClassName}.${methodName}`
1402
+
1403
+ if (!resourceMethodParameterTypes.has(parameterTypesKey)) return null
1404
+
1405
+ const parameterTypes = resourceMethodParameterTypes.get(parameterTypesKey)
1406
+
1407
+ if (!parameterTypes) {
1408
+ throw new Error(`Expected JSDoc parameter types for ${parameterTypesKey}`)
1409
+ }
1410
+
1411
+ const parameterType = parameterTypes[parameterIndex]
1412
+
1413
+ if (parameterType === undefined) return null
1414
+
1415
+ if (parameterType.length < 1) {
1416
+ throw new Error(`Expected non-empty JSDoc parameter type for ${parameterTypesKey} parameter ${parameterIndex}`)
1417
+ }
1418
+
1419
+ return parameterType
1420
+ }
1421
+
1422
+ /**
1423
+ * Runs resource method return types.
1424
+ * @returns {Promise<Map<string, string>>} - Resource method return types keyed by ClassName.methodName.
1425
+ */
1426
+ async resourceMethodReturnTypes() {
1427
+ if (this._resourceMethodReturnTypes) return this._resourceMethodReturnTypes
1428
+
1429
+ const sourceFiles = await this.frontendModelJsDocSourceFiles()
1430
+ const returnTypes = new Map()
1431
+
1432
+ for (const sourceFile of sourceFiles) {
1433
+ const sourceText = await fs.readFile(sourceFile, "utf8")
1434
+
1435
+ this.addResourceMethodReturnTypesFromSource({returnTypes, sourceText})
1436
+ }
1437
+
1438
+ this._resourceMethodReturnTypes = returnTypes
1439
+
1440
+ return returnTypes
1441
+ }
1442
+
1443
+ /**
1444
+ * Runs resource method parameter types.
1445
+ * @returns {Promise<Map<string, string[]>>} - Resource method parameter types keyed by ClassName.methodName.
1446
+ */
1447
+ async resourceMethodParameterTypes() {
1448
+ if (this._resourceMethodParameterTypes) return this._resourceMethodParameterTypes
1449
+
1450
+ const sourceFiles = await this.frontendModelJsDocSourceFiles()
1451
+ const parameterTypes = new Map()
1452
+
1453
+ for (const sourceFile of sourceFiles) {
1454
+ const sourceText = await fs.readFile(sourceFile, "utf8")
1455
+
1456
+ this.addResourceMethodParameterTypesFromSource({parameterTypes, sourceText})
1457
+ }
1458
+
1459
+ this._resourceMethodParameterTypes = parameterTypes
1460
+
1461
+ return parameterTypes
1462
+ }
1463
+
1464
+ /**
1465
+ * Runs frontend model JSDoc source files.
1466
+ * @returns {Promise<string[]>} - JavaScript source files that can define frontend-model resources and model accessors.
1467
+ */
1468
+ async frontendModelJsDocSourceFiles() {
1469
+ const sourceFiles = []
1470
+
1471
+ for (const sourceDirectory of this.frontendModelJsDocSourceDirectories()) {
1472
+ sourceFiles.push(...await this.javascriptFilesInDirectory(sourceDirectory))
1473
+ }
1474
+
1475
+ return sourceFiles
1476
+ }
1477
+
1478
+ /**
1479
+ * Runs frontend model JSDoc source directories.
1480
+ * @returns {string[]} - Source directories to scan for generated frontend-model JSDoc.
1481
+ */
1482
+ frontendModelJsDocSourceDirectories() {
1483
+ const sourceDirectories = new Set([path.join(this.directory(), "src")])
1484
+
1485
+ for (const backendProject of this.getConfiguration().getBackendProjects()) {
1486
+ if (typeof backendProject.path == "string" && backendProject.path.length > 0) {
1487
+ sourceDirectories.add(path.join(backendProject.path, "src"))
1488
+ }
1489
+ }
1490
+
1491
+ return Array.from(sourceDirectories)
1492
+ }
1493
+
1494
+ /**
1495
+ * Adds resource method return types from source.
1496
+ * @param {object} args - Arguments.
1497
+ * @param {Map<string, string>} args.returnTypes - Mutable return types map.
1498
+ * @param {string} args.sourceText - Source text.
1499
+ * @returns {void}
1500
+ */
1501
+ addResourceMethodReturnTypesFromSource({returnTypes, sourceText}) {
1502
+ const classRegex = /class\s+([A-Za-z_$][\w$]*)\s+(?:extends\s+[^{]+)?\{/g
1503
+ let classMatch
1504
+
1505
+ while ((classMatch = classRegex.exec(sourceText))) {
1506
+ const className = classMatch[1]
1507
+ const classBodyStart = classRegex.lastIndex
1508
+ const classBodyEnd = this.matchingBraceIndex({openIndex: classBodyStart - 1, sourceText})
1509
+
1510
+ if (classBodyEnd == null) {
1511
+ throw new Error(`Could not find closing brace for resource class '${className}' while reading frontend attribute JSDoc`)
1512
+ }
1513
+
1514
+ const classBody = sourceText.slice(classBodyStart, classBodyEnd)
1515
+ const jsDocRegex = /\/\*\*([\s\S]*?)\*\//g
1516
+ let jsDocMatch
1517
+
1518
+ while ((jsDocMatch = jsDocRegex.exec(classBody))) {
1519
+ const sourceAfterJsDoc = classBody.slice(jsDocRegex.lastIndex)
1520
+ const methodMatch = sourceAfterJsDoc.match(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/)
1521
+
1522
+ if (!methodMatch) continue
1523
+
1524
+ const methodName = methodMatch[1]
1525
+
1526
+ const returnType = this.jsDocReturnType(jsDocMatch[1])
1527
+
1528
+ if (returnType) {
1529
+ returnTypes.set(`${className}.${methodName}`, returnType)
1530
+ }
1531
+ }
1532
+
1533
+ classRegex.lastIndex = classBodyEnd + 1
1534
+ }
1535
+ }
1536
+
1537
+ /**
1538
+ * Adds resource method parameter types from source.
1539
+ * @param {{parameterTypes: Map<string, string[]>, sourceText: string}} args - Arguments.
1540
+ * @returns {void}
1541
+ */
1542
+ addResourceMethodParameterTypesFromSource({parameterTypes, sourceText}) {
1543
+ const classRegex = /class\s+([A-Za-z_$][\w$]*)\s+(?:extends\s+[^{]+)?\{/g
1544
+ let classMatch
1545
+
1546
+ while ((classMatch = classRegex.exec(sourceText))) {
1547
+ const className = classMatch[1]
1548
+ const classBodyStart = classRegex.lastIndex
1549
+ const classBodyEnd = this.matchingBraceIndex({openIndex: classBodyStart - 1, sourceText})
1550
+
1551
+ if (classBodyEnd == null) {
1552
+ throw new Error(`Could not find closing brace for resource class '${className}' while reading frontend attribute JSDoc`)
1553
+ }
1554
+
1555
+ const classBody = sourceText.slice(classBodyStart, classBodyEnd)
1556
+ const jsDocRegex = /\/\*\*([\s\S]*?)\*\//g
1557
+ let jsDocMatch
1558
+
1559
+ while ((jsDocMatch = jsDocRegex.exec(classBody))) {
1560
+ const sourceAfterJsDoc = classBody.slice(jsDocRegex.lastIndex)
1561
+ const methodMatch = sourceAfterJsDoc.match(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/)
1562
+
1563
+ if (!methodMatch) continue
1564
+
1565
+ const methodName = methodMatch[1]
1566
+ const jsDocParameterTypes = this.jsDocParameterTypes(jsDocMatch[1])
1567
+
1568
+ if (jsDocParameterTypes.length > 0) {
1569
+ parameterTypes.set(`${className}.${methodName}`, jsDocParameterTypes)
1570
+ }
1571
+ }
1572
+
1573
+ classRegex.lastIndex = classBodyEnd + 1
1574
+ }
1575
+ }
1576
+
1577
+ /**
1578
+ * Runs js doc return type.
1579
+ * @param {string} jsDocText - JSDoc text inside comment markers.
1580
+ * @returns {string | null} - JSDoc return type when present.
1581
+ */
1582
+ jsDocReturnType(jsDocText) {
1583
+ const returnsMatch = jsDocText.match(/@returns?\s*\{/)
1584
+
1585
+ if (!returnsMatch || returnsMatch.index == null) return null
1586
+
1587
+ const typeOpenIndex = returnsMatch.index + returnsMatch[0].length - 1
1588
+ const typeCloseIndex = this.matchingBraceIndex({openIndex: typeOpenIndex, sourceText: jsDocText})
1589
+
1590
+ if (typeCloseIndex == null) {
1591
+ throw new Error(`Could not parse JSDoc return type from: ${jsDocText}`)
1592
+ }
1593
+
1594
+ const returnType = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex).trim()
1595
+
1596
+ if (returnType.length < 1) {
1597
+ throw new Error(`Expected non-empty JSDoc return type in: ${jsDocText}`)
1598
+ }
1599
+
1600
+ return returnType
1601
+ }
1602
+
1603
+ /**
1604
+ * Runs js doc parameter types.
1605
+ * @param {string} jsDocText - JSDoc text inside comment markers.
1606
+ * @returns {string[]} - JSDoc parameter types in declaration order.
1607
+ */
1608
+ jsDocParameterTypes(jsDocText) {
1609
+ const parameterTypes = []
1610
+ const paramRegex = /@param\s*\{/g
1611
+ let _paramMatch
1612
+
1613
+ while ((_paramMatch = paramRegex.exec(jsDocText))) {
1614
+ const typeOpenIndex = paramRegex.lastIndex - 1
1615
+ const typeCloseIndex = this.matchingBraceIndex({openIndex: typeOpenIndex, sourceText: jsDocText})
1616
+
1617
+ if (typeCloseIndex == null) {
1618
+ throw new Error(`Could not parse JSDoc parameter type from: ${jsDocText}`)
1619
+ }
1620
+
1621
+ const parameterType = jsDocText.slice(typeOpenIndex + 1, typeCloseIndex).trim()
1622
+
1623
+ if (parameterType.length < 1) {
1624
+ throw new Error(`Expected non-empty JSDoc parameter type in: ${jsDocText}`)
1625
+ }
1626
+
1627
+ parameterTypes.push(parameterType)
1628
+ paramRegex.lastIndex = typeCloseIndex + 1
1629
+ }
1630
+
1631
+ return parameterTypes
1632
+ }
1633
+
1634
+ /**
1635
+ * Runs javascript files in directory.
1636
+ * @param {string} directory - Directory path.
1637
+ * @returns {Promise<string[]>} - JavaScript source file paths.
1638
+ */
1639
+ async javascriptFilesInDirectory(directory) {
1640
+ let entries
1641
+
1642
+ try {
1643
+ entries = await fs.readdir(directory, {withFileTypes: true})
1644
+ } catch (error) {
1645
+ if (error && typeof error == "object" && "code" in error && error.code === "ENOENT") return []
1646
+
1647
+ throw error
1648
+ }
1649
+
1650
+ const filePaths = []
1651
+
1652
+ for (const entry of entries) {
1653
+ const entryPath = path.join(directory, entry.name)
1654
+
1655
+ if (entry.isDirectory()) {
1656
+ filePaths.push(...await this.javascriptFilesInDirectory(entryPath))
1657
+ } else if (entry.isFile() && /\.(mjs|js|jsx|ts)$/.test(entry.name)) {
1658
+ filePaths.push(entryPath)
1659
+ }
1660
+ }
1661
+
1662
+ return filePaths
1663
+ }
1664
+
1665
+ /**
1666
+ * Finds a matching closing brace while respecting JavaScript strings and comments.
1667
+ * @param {object} args - Arguments.
1668
+ * @param {number} args.openIndex - Opening brace index.
1669
+ * @param {string} args.sourceText - Source text.
1670
+ * @returns {number | null} - Closing brace index when found.
1671
+ */
1672
+ matchingBraceIndex({openIndex, sourceText}) {
1673
+ if (sourceText[openIndex] !== "{") {
1674
+ throw new Error(`Expected opening brace at index ${openIndex}`)
1675
+ }
1676
+
1677
+ let depth = 0
1678
+ let inBlockComment = false
1679
+ let inLineComment = false
1680
+ let inString = ""
1681
+
1682
+ for (let index = openIndex; index < sourceText.length; index++) {
1683
+ const char = sourceText[index]
1684
+ const nextChar = sourceText[index + 1]
1685
+ const previousChar = sourceText[index - 1]
1686
+
1687
+ if (inLineComment) {
1688
+ if (char === "\n") inLineComment = false
1689
+
1690
+ continue
1691
+ }
1692
+
1693
+ if (inBlockComment) {
1694
+ if (char === "*" && nextChar === "/") {
1695
+ inBlockComment = false
1696
+ index++
1697
+ }
1698
+
1699
+ continue
1700
+ }
1701
+
1702
+ if (inString) {
1703
+ if (char === inString && previousChar !== "\\") inString = ""
1704
+
1705
+ continue
1706
+ }
1707
+
1708
+ if (char === "/" && nextChar === "/") {
1709
+ inLineComment = true
1710
+ index++
1711
+ continue
1712
+ }
1713
+
1714
+ if (char === "/" && nextChar === "*") {
1715
+ inBlockComment = true
1716
+ index++
1717
+ continue
1718
+ }
1719
+
1720
+ if (char === "\"" || char === "'" || char === "`") {
1721
+ inString = char
1722
+ continue
1723
+ }
1724
+
1725
+ if (char === "{") {
1726
+ depth++
1727
+ } else if (char === "}") {
1728
+ depth--
1729
+
1730
+ if (depth === 0) return index
1731
+ }
1732
+ }
1733
+
1734
+ return null
1735
+ }
1736
+
1040
1737
  /**
1041
1738
  * Runs frontend attribute config for model attribute.
1042
1739
  * @param {object} args - Arguments.
@@ -1049,13 +1746,54 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1049
1746
  return null
1050
1747
  }
1051
1748
 
1052
- const columnName = modelClass.getAttributeNameToColumnNameMap()[attributeName]
1749
+ const resolvedAttributeName = modelClass.resolveAttributeName(attributeName)
1750
+
1751
+ if (!resolvedAttributeName) return null
1752
+
1753
+ let columnName
1754
+
1755
+ try {
1756
+ columnName = modelClass.getAttributeNameToColumnNameMap()[resolvedAttributeName]
1757
+ } catch (error) {
1758
+ if (error instanceof Error && error.message.includes("used before initialization")) return null
1759
+
1760
+ throw error
1761
+ }
1053
1762
 
1054
1763
  if (!columnName) {
1055
1764
  return null
1056
1765
  }
1057
1766
 
1058
- return modelClass.getColumnsHash()[columnName] || null
1767
+ let column
1768
+
1769
+ try {
1770
+ column = modelClass.getColumnsHash()[columnName]
1771
+ } catch (error) {
1772
+ if (error instanceof Error && error.message.includes("used before initialization")) return null
1773
+
1774
+ throw error
1775
+ }
1776
+
1777
+ return column ? this.frontendAttributeConfigForColumn({column}) : null
1778
+ }
1779
+
1780
+ /**
1781
+ * Runs frontend attribute config for column.
1782
+ * @param {object} args - Arguments.
1783
+ * @param {import("../../../../../database/drivers/base-column.js").default} args.column - Database column.
1784
+ * @returns {FrontendAttributeConfig} - Attribute config inferred from the database column.
1785
+ */
1786
+ frontendAttributeConfigForColumn({column}) {
1787
+ const type = column.getType()
1788
+
1789
+ if (typeof type != "string" || type.length < 1) {
1790
+ throw new Error(`Expected non-empty column type for frontend model attribute inference, got: ${type}`)
1791
+ }
1792
+
1793
+ return {
1794
+ null: column.getNull(),
1795
+ type
1796
+ }
1059
1797
  }
1060
1798
 
1061
1799
  /**