velocious 1.0.469 → 1.0.471

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 (74) hide show
  1. package/README.md +4 -2
  2. package/build/configuration-types.js +1 -0
  3. package/build/configuration.js +27 -1
  4. package/build/controller.js +13 -1
  5. package/build/database/datetime-storage.js +49 -5
  6. package/build/database/record/index.js +14 -4
  7. package/build/environment-handlers/base.js +46 -0
  8. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +487 -40
  9. package/build/environment-handlers/node.js +84 -8
  10. package/build/frontend-model-controller.js +7 -7
  11. package/build/frontend-models/base.js +75 -9
  12. package/build/frontend-models/transport-serialization.js +41 -8
  13. package/build/frontend-models/websocket-channel.js +18 -1
  14. package/build/frontend-models/websocket-publishers.js +12 -1
  15. package/build/http-server/client/request-runner.js +13 -1
  16. package/build/src/configuration-types.d.ts +5 -0
  17. package/build/src/configuration-types.d.ts.map +1 -1
  18. package/build/src/configuration-types.js +2 -1
  19. package/build/src/configuration.d.ts +14 -1
  20. package/build/src/configuration.d.ts.map +1 -1
  21. package/build/src/configuration.js +25 -2
  22. package/build/src/controller.d.ts +5 -0
  23. package/build/src/controller.d.ts.map +1 -1
  24. package/build/src/controller.js +12 -2
  25. package/build/src/database/datetime-storage.d.ts +12 -4
  26. package/build/src/database/datetime-storage.d.ts.map +1 -1
  27. package/build/src/database/datetime-storage.js +48 -6
  28. package/build/src/database/record/index.d.ts +5 -0
  29. package/build/src/database/record/index.d.ts.map +1 -1
  30. package/build/src/database/record/index.js +13 -5
  31. package/build/src/environment-handlers/base.d.ts +19 -0
  32. package/build/src/environment-handlers/base.d.ts.map +1 -1
  33. package/build/src/environment-handlers/base.js +41 -1
  34. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +253 -40
  35. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  36. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +425 -41
  37. package/build/src/environment-handlers/node.d.ts +1 -0
  38. package/build/src/environment-handlers/node.d.ts.map +1 -1
  39. package/build/src/environment-handlers/node.js +76 -9
  40. package/build/src/frontend-model-controller.js +8 -8
  41. package/build/src/frontend-models/base.d.ts +4 -0
  42. package/build/src/frontend-models/base.d.ts.map +1 -1
  43. package/build/src/frontend-models/base.js +64 -9
  44. package/build/src/frontend-models/transport-serialization.d.ts +30 -1
  45. package/build/src/frontend-models/transport-serialization.d.ts.map +1 -1
  46. package/build/src/frontend-models/transport-serialization.js +34 -9
  47. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  48. package/build/src/frontend-models/websocket-channel.js +16 -2
  49. package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
  50. package/build/src/frontend-models/websocket-publishers.js +12 -2
  51. package/build/src/http-server/client/request-runner.d.ts.map +1 -1
  52. package/build/src/http-server/client/request-runner.js +13 -2
  53. package/build/src/time-zone.d.ts +16 -0
  54. package/build/src/time-zone.d.ts.map +1 -0
  55. package/build/src/time-zone.js +56 -0
  56. package/build/time-zone.js +65 -0
  57. package/build/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +2 -1
  59. package/scripts/test-browser.js +1 -0
  60. package/src/configuration-types.js +1 -0
  61. package/src/configuration.js +27 -1
  62. package/src/controller.js +13 -1
  63. package/src/database/datetime-storage.js +49 -5
  64. package/src/database/record/index.js +14 -4
  65. package/src/environment-handlers/base.js +46 -0
  66. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +487 -40
  67. package/src/environment-handlers/node.js +84 -8
  68. package/src/frontend-model-controller.js +7 -7
  69. package/src/frontend-models/base.js +75 -9
  70. package/src/frontend-models/transport-serialization.js +41 -8
  71. package/src/frontend-models/websocket-channel.js +18 -1
  72. package/src/frontend-models/websocket-publishers.js +12 -1
  73. package/src/http-server/client/request-runner.js +13 -1
  74. package/src/time-zone.js +65 -0
@@ -23,13 +23,34 @@ import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigura
23
23
  * Permit spec returned by frontend-model resources during generation.
24
24
  * @typedef {Array<string | Record<string, FrontendModelGeneratorPermitSpec>>} FrontendModelGeneratorPermitSpec
25
25
  */
26
+ /**
27
+ * JSDoc import alias extracted from a backend resource source file.
28
+ * @typedef {object} ResourceJsDocImportAlias
29
+ * @property {string} importedName - Exported type name.
30
+ * @property {string} specifier - Import specifier from the source file.
31
+ */
32
+ /**
33
+ * JSDoc return type extracted from a backend resource method.
34
+ * @typedef {object} ResourceMethodReturnType
35
+ * @property {Map<string, ResourceJsDocImportAlias>} importAliases - Import aliases visible in the source file.
36
+ * @property {string | null} sourceFile - Source file that declared the method.
37
+ * @property {string} type - JSDoc return type.
38
+ */
39
+ /**
40
+ * JSDoc parameter type extracted from a backend resource method.
41
+ * @typedef {object} ResourceMethodParameterType
42
+ * @property {Map<string, ResourceJsDocImportAlias>} importAliases - Import aliases visible in the source file.
43
+ * @property {string | null} name - Parameter name.
44
+ * @property {string | null} sourceFile - Source file that declared the method.
45
+ * @property {string} type - JSDoc parameter type.
46
+ */
26
47
 
27
48
  /** Node CLI command that generates frontend model classes from backend project resource config. */
28
49
  export default class DbGenerateFrontendModels extends BaseCommand {
29
- /** @type {Map<string, string> | null} */
50
+ /** @type {Map<string, ResourceMethodReturnType> | null} */
30
51
  _resourceMethodReturnTypes = null
31
52
 
32
- /** @type {Map<string, Array<{name: string | null, type: string}>> | null} */
53
+ /** @type {Map<string, ResourceMethodParameterType[]> | null} */
33
54
  _resourceMethodParameterTypes = null
34
55
 
35
56
  /**
@@ -128,6 +149,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
128
149
 
129
150
  const fileContent = await this.buildModelFileContent({
130
151
  className,
152
+ frontendModelFilePath: filePath,
131
153
  importPath,
132
154
  modelClass: resourceClass ? resourceClass.modelClass() : configuration.getModelClasses()[className],
133
155
  modelConfig,
@@ -253,13 +275,14 @@ export default class DbGenerateFrontendModels extends BaseCommand {
253
275
  * Runs build model file content.
254
276
  * @param {object} args - Method args.
255
277
  * @param {string} args.className - Model class name.
278
+ * @param {string} args.frontendModelFilePath - Generated frontend model file path.
256
279
  * @param {string} args.importPath - Base class import path.
257
280
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
258
281
  * @param {import("../../../../../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.modelConfig - Model configuration.
259
282
  * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null} [args.resourceClass] - Resource class.
260
283
  * @returns {Promise<string>} - Generated file content.
261
284
  */
262
- async buildModelFileContent({className, importPath, modelClass, modelConfig, resourceClass}) {
285
+ async buildModelFileContent({className, frontendModelFilePath, importPath, modelClass, modelConfig, resourceClass}) {
263
286
  const attributes = await this.attributeDefinitionsForModel({className, modelClass, modelConfig, resourceClass})
264
287
  const relationships = this.relationshipsForModel({className, modelConfig, resourceClass})
265
288
  const attachments = modelConfig.attachments && typeof modelConfig.attachments === "object"
@@ -292,6 +315,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
292
315
  const commandMetadata = await this.commandMetadataWithResourceJsDoc({
293
316
  commandMetadata: declaredCommandMetadata,
294
317
  commandNames: [...Object.keys(collectionCommands), ...Object.keys(memberCommands)],
318
+ frontendModelFilePath,
295
319
  resourceClass
296
320
  })
297
321
  const builtInCollectionCommandsAreDefault = builtInCollectionCommands.create === "create" && builtInCollectionCommands.index === "index"
@@ -633,7 +657,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
633
657
  /**
634
658
  * Runs write attributes typedef.
635
659
  * @param {object} args - Arguments.
636
- * @param {Array<{jsDocType: string, name: string}>} args.attributes - Generated read attributes.
660
+ * @param {Array<{jsDocType: string, name: string, writeJsDocType: string}>} args.attributes - Generated read attributes.
637
661
  * @param {string} args.attributesTypeName - Generated read attributes typedef name.
638
662
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
639
663
  * @param {Array<{attributes: Array<{name: string, type: string}>, relationshipName: string, typeName: string}>} args.nestedWriteTypes - Nested write typedefs.
@@ -691,7 +715,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
691
715
 
692
716
  /**
693
717
  * Runs frontend write attribute type.
694
- * @param {{attribute: {jsDocType: string, name: string} | undefined, attributeName: string, attributesTypeName: string, resourceClass: import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined}} args - Arguments.
718
+ * @param {{attribute: {jsDocType: string, name: string, writeJsDocType: string} | undefined, attributeName: string, attributesTypeName: string, resourceClass: import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined}} args - Arguments.
695
719
  * @returns {Promise<string>} - JSDoc type for the permitted write field.
696
720
  */
697
721
  async frontendWriteAttributeType({attribute, attributeName, attributesTypeName, resourceClass}) {
@@ -703,6 +727,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
703
727
 
704
728
  if (attribute.jsDocType.trim() === "null") return "FrontendModelAttributeValue"
705
729
 
730
+ if (attribute.writeJsDocType !== attribute.jsDocType) return attribute.writeJsDocType
731
+
706
732
  return `${attributesTypeName}[${JSON.stringify(attribute.name)}] | null`
707
733
  }
708
734
 
@@ -823,7 +849,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
823
849
 
824
850
  return {
825
851
  name: resolvedAttributeName,
826
- type: attributeConfig ? this.jsDocTypeForFrontendAttribute({attributeConfig}) : "FrontendModelAttributeValue"
852
+ type: attributeConfig ? this.jsDocTypeForFrontendWriteAttribute({attributeConfig}) : "FrontendModelAttributeValue"
827
853
  }
828
854
  })
829
855
  }
@@ -979,7 +1005,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
979
1005
  * @param {typeof import("../../../../../database/record/index.js").default | undefined} args.modelClass - Backend model class.
980
1006
  * @param {import("../../../../../configuration-types.js").NormalizedFrontendModelResourceConfiguration} args.modelConfig - Model configuration.
981
1007
  * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null} [args.resourceClass] - Resource class.
982
- * @returns {Promise<Array<{jsDocType: string, name: string}>>} - Attribute definitions.
1008
+ * @returns {Promise<Array<{jsDocType: string, name: string, writeJsDocType: string}>>} - Attribute definitions.
983
1009
  */
984
1010
  async attributeDefinitionsForModel({className, modelClass, modelConfig, resourceClass}) {
985
1011
  let attributes = modelConfig.attributes
@@ -1028,7 +1054,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1028
1054
 
1029
1055
  attributeDefinitions.push({
1030
1056
  jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig: frontendAttributeConfig}),
1031
- name: attributeName
1057
+ name: attributeName,
1058
+ writeJsDocType: this.jsDocTypeForFrontendWriteAttribute({attributeConfig: frontendAttributeConfig})
1032
1059
  })
1033
1060
  }
1034
1061
 
@@ -1061,7 +1088,8 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1061
1088
 
1062
1089
  attributeDefinitions.push({
1063
1090
  jsDocType: this.jsDocTypeForFrontendAttribute({attributeConfig: frontendAttributeConfig}),
1064
- name: attributeName
1091
+ name: attributeName,
1092
+ writeJsDocType: this.jsDocTypeForFrontendWriteAttribute({attributeConfig: frontendAttributeConfig})
1065
1093
  })
1066
1094
  }
1067
1095
 
@@ -1236,6 +1264,39 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1236
1264
  return `${jsDocType} | null`
1237
1265
  }
1238
1266
 
1267
+ /**
1268
+ * Runs js doc type for frontend write attribute.
1269
+ * @param {object} args - Arguments.
1270
+ * @param {FrontendAttributeConfig | null | undefined} args.attributeConfig - Attribute configuration value.
1271
+ * @returns {string} - JSDoc type accepted by create/update payloads.
1272
+ */
1273
+ jsDocTypeForFrontendWriteAttribute({attributeConfig}) {
1274
+ if (attributeConfig && typeof attributeConfig.jsDocType == "string" && attributeConfig.jsDocType.length > 0) {
1275
+ return attributeConfig.jsDocType
1276
+ }
1277
+
1278
+ const jsDocType = this.jsDocTypeForFrontendWriteAttributeBaseType(attributeConfig)
1279
+
1280
+ if (!this.frontendAttributeCanBeNull(attributeConfig)) {
1281
+ return jsDocType
1282
+ }
1283
+
1284
+ return `${jsDocType} | null`
1285
+ }
1286
+
1287
+ /**
1288
+ * Runs js doc type for frontend write attribute base type.
1289
+ * @param {FrontendAttributeConfig | null | undefined} attributeConfig - Attribute configuration value.
1290
+ * @returns {string} - Non-nullable JSDoc type accepted by create/update payloads.
1291
+ */
1292
+ jsDocTypeForFrontendWriteAttributeBaseType(attributeConfig) {
1293
+ const readType = this.jsDocTypeForFrontendAttributeBaseType(attributeConfig)
1294
+
1295
+ if (!this.frontendAttributeTypeIsTemporal(attributeConfig)) return readType
1296
+
1297
+ return `${readType} | string`
1298
+ }
1299
+
1239
1300
  /**
1240
1301
  * Runs js doc type for frontend attribute base type.
1241
1302
  * @param {FrontendAttributeConfig | null | undefined} attributeConfig - Attribute configuration value.
@@ -1256,13 +1317,26 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1256
1317
  return "string"
1257
1318
  } else if (type && ["bit", "bigint", "decimal", "double", "double precision", "float", "int", "integer", "numeric", "real", "smallint", "tinyint"].includes(type)) {
1258
1319
  return "number"
1259
- } else if (type && ["date", "datetime", "timestamp", "timestamp without time zone", "timestamptz"].includes(type)) {
1320
+ } else if (this.frontendAttributeTypeIsTemporal(attributeConfig)) {
1260
1321
  return "Date"
1261
1322
  } else {
1262
1323
  return "FrontendModelAttributeValue"
1263
1324
  }
1264
1325
  }
1265
1326
 
1327
+ /**
1328
+ * Runs frontend attribute type is temporal.
1329
+ * @param {FrontendAttributeConfig | null | undefined} attributeConfig - Attribute configuration value.
1330
+ * @returns {boolean} - Whether the attribute represents a date/time value.
1331
+ */
1332
+ frontendAttributeTypeIsTemporal(attributeConfig) {
1333
+ if (!attributeConfig || typeof attributeConfig !== "object") return false
1334
+
1335
+ const type = this.frontendAttributeTypeValue(attributeConfig)
1336
+
1337
+ return type ? ["date", "datetime", "timestamp", "timestamp without time zone", "timestamptz"].includes(type) : false
1338
+ }
1339
+
1266
1340
  /**
1267
1341
  * Runs frontend attribute can be null.
1268
1342
  * @param {FrontendAttributeConfig | null | undefined} attributeConfig - Attribute configuration value.
@@ -1437,25 +1511,209 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1437
1511
 
1438
1512
  /**
1439
1513
  * Rewrites a custom-command param/return JSDoc type so it resolves in the generated
1440
- * frontend model: each model-class (or otherwise non-frontend-resolvable) identifier
1441
- * becomes `any` in place, keeping the surrounding object fields typed. A command-result
1442
- * field holding a model arrives as a serialized transport value, so the consumer hydrates
1443
- * it with `Model.instantiateFromResponse(...)`. The word boundary avoids matching the
1444
- * capitalized middle of a camelCase property name (e.g. `adjustedTotalCents`).
1445
- * @param {string} jsDocType - Resolved (Promise-unwrapped) JSDoc type.
1514
+ * frontend model: backend model imports are mapped to generated frontend model
1515
+ * imports, and otherwise non-frontend-resolvable identifiers become `any` in place
1516
+ * so sibling scalar fields keep their real types. The word boundary avoids matching
1517
+ * the capitalized middle of a camelCase property name (e.g. `adjustedTotalCents`).
1518
+ * @param {object} args - Arguments.
1519
+ * @param {string | null} args.frontendModelFilePath - Generated frontend model file path.
1520
+ * @param {Map<string, ResourceJsDocImportAlias>} args.importAliases - Import aliases visible to the source method.
1521
+ * @param {string} args.jsDocType - Resolved (Promise-unwrapped) JSDoc type.
1522
+ * @param {string | null} args.sourceFile - Source file that declared the method.
1446
1523
  * @returns {string} - A frontend-resolvable JSDoc type.
1447
1524
  */
1448
- frontendResolvableCommandJsDocType(jsDocType) {
1525
+ frontendResolvableCommandJsDocType({frontendModelFilePath, importAliases, jsDocType, sourceFile}) {
1449
1526
  const safeTypeIdentifiers = this.frontendResolvableTypeIdentifiers()
1527
+ /** @type {string[]} */
1528
+ const preservedImports = []
1529
+ /**
1530
+ * Stores an import expression behind a lowercase placeholder while generic
1531
+ * identifier cleanup runs.
1532
+ * @param {string} importExpression - Import expression to preserve.
1533
+ * @returns {string} Placeholder inserted into the type string.
1534
+ */
1535
+ const preserveImportExpression = (importExpression) => {
1536
+ const placeholder = `__velocious_import_placeholder_${preservedImports.length}__`
1537
+
1538
+ preservedImports.push(importExpression)
1539
+
1540
+ return placeholder
1541
+ }
1542
+
1543
+ this.assertNoBackendLocalCommandTypeExpressions(jsDocType)
1544
+
1545
+ const withRewrittenInlineImports = jsDocType
1546
+ // A type that reaches into a backend source file via `import("...")`
1547
+ // (optionally `.Member` and `[]`) is frontend-resolvable only when it
1548
+ // points at a generated model file; other backend-local imports collapse
1549
+ // to `any` so helper/service implementation details do not leak.
1550
+ .replace(/import\(\s*["']([^"']*)["']\s*\)((?:\s*\.\s*[A-Za-z_$][\w$]*)*)((?:\s*\[\s*\])*)/g, (_match, specifier, memberChain, arraySuffix) => {
1551
+ const rewrittenSpecifier = this.frontendResolvableJsDocImportSpecifier({
1552
+ frontendModelFilePath,
1553
+ sourceFile,
1554
+ specifier
1555
+ })
1450
1556
 
1451
- return jsDocType
1452
- // A type that reaches into a backend source file via `import("...")` (optionally
1453
- // `.Member` and `[]`) can't resolve from the generated frontend model and would type
1454
- // a serialized result as a backend model instance, so collapse it to `any`.
1455
- .replace(/import\(\s*["'][^"']*["']\s*\)(\s*\.\s*[A-Za-z_$][\w$]*)*(\s*\[\s*\])*/g, "any")
1557
+ if (!rewrittenSpecifier) return "any"
1558
+
1559
+ return preserveImportExpression(`import(${JSON.stringify(rewrittenSpecifier)})${memberChain.replace(/\s+/g, "")}${arraySuffix.replace(/\s+/g, "")}`)
1560
+ })
1561
+
1562
+ let withRewrittenAliases = withRewrittenInlineImports
1563
+
1564
+ for (const [aliasName, importAlias] of importAliases) {
1565
+ const rewrittenSpecifier = this.frontendResolvableJsDocImportSpecifier({
1566
+ frontendModelFilePath,
1567
+ sourceFile,
1568
+ specifier: importAlias.specifier
1569
+ })
1570
+
1571
+ if (!rewrittenSpecifier) continue
1572
+
1573
+ const aliasRegex = new RegExp(`\\b${this.escapeRegExp(aliasName)}\\b`, "g")
1574
+
1575
+ withRewrittenAliases = withRewrittenAliases.replace(aliasRegex, preserveImportExpression(`import(${JSON.stringify(rewrittenSpecifier)}).${importAlias.importedName}`))
1576
+ }
1577
+
1578
+ const sanitized = withRewrittenAliases
1456
1579
  // Remaining capitalized identifiers are model classes or otherwise non-resolvable
1457
1580
  // types; downgrade each in place so sibling scalar fields keep their real types.
1458
1581
  .replace(/\b[A-Z][A-Za-z0-9_$]*/g, (identifier) => safeTypeIdentifiers.has(identifier) ? identifier : "any")
1582
+
1583
+ return preservedImports.reduce(
1584
+ (type, importExpression, index) => type.replaceAll(`__velocious_import_placeholder_${index}__`, importExpression),
1585
+ sanitized
1586
+ )
1587
+ }
1588
+
1589
+ /**
1590
+ * Raises when a command JSDoc type references a backend-local helper expression.
1591
+ * @param {string} jsDocType - Command JSDoc type.
1592
+ * @returns {void} No return value.
1593
+ */
1594
+ assertNoBackendLocalCommandTypeExpressions(jsDocType) {
1595
+ const localReturnTypeMatch = jsDocType.match(/\b(?:Awaited\s*<\s*)?ReturnType\s*<\s*typeof\s+[A-Za-z_$][\w$]*\s*>\s*>?/)
1596
+
1597
+ if (!localReturnTypeMatch) return
1598
+
1599
+ throw new Error(`Custom command JSDoc type cannot use backend-local ReturnType expressions in generated frontend models: ${localReturnTypeMatch[0]}. Move the payload shape to a shared typedef and return that type from the command method.`)
1600
+ }
1601
+
1602
+ /**
1603
+ * Runs frontend resolvable js doc import specifier.
1604
+ * @param {object} args - Arguments.
1605
+ * @param {string | null} args.frontendModelFilePath - Generated frontend model file path.
1606
+ * @param {string | null} args.sourceFile - Source file that declared the JSDoc type.
1607
+ * @param {string} args.specifier - Source-file import specifier.
1608
+ * @returns {string | null} - Rewritten frontend-model import specifier, or null when backend-local.
1609
+ */
1610
+ frontendResolvableJsDocImportSpecifier({frontendModelFilePath, sourceFile, specifier}) {
1611
+ if (!sourceFile || !frontendModelFilePath) return null
1612
+ if (!specifier.startsWith(".") && !specifier.startsWith("/")) return specifier
1613
+
1614
+ const importedPath = path.resolve(path.dirname(sourceFile), specifier)
1615
+ const modelImportSpecifier = this.frontendModelImportSpecifierForBackendModelPath({
1616
+ frontendModelFilePath,
1617
+ importedPath
1618
+ })
1619
+
1620
+ if (modelImportSpecifier) return modelImportSpecifier
1621
+
1622
+ if (this.filePathIsWithinAnyDirectory({directories: this.frontendModelJsDocSourceDirectories(), filePath: importedPath})) {
1623
+ return null
1624
+ }
1625
+
1626
+ return this.relativeImportSpecifier({fromFile: frontendModelFilePath, toFile: importedPath})
1627
+ }
1628
+
1629
+ /**
1630
+ * Runs frontend model import specifier for backend model path.
1631
+ * @param {object} args - Arguments.
1632
+ * @param {string} args.frontendModelFilePath - Generated frontend model file path.
1633
+ * @param {string} args.importedPath - Source-file import path resolved from JSDoc.
1634
+ * @returns {string | null} - Generated frontend-model import specifier, or null when the path is not a registered model file.
1635
+ */
1636
+ frontendModelImportSpecifierForBackendModelPath({frontendModelFilePath, importedPath}) {
1637
+ const frontendModelsDirectory = path.dirname(frontendModelFilePath)
1638
+ const importedModelPath = importedPath.endsWith(".js") ? importedPath : `${importedPath}.js`
1639
+
1640
+ for (const modelFileName of this.generatedFrontendModelFileNames()) {
1641
+ for (const sourceDirectory of this.frontendModelJsDocSourceDirectories()) {
1642
+ const modelsDirectory = path.join(sourceDirectory, "models")
1643
+ const candidateModelPath = path.join(modelsDirectory, modelFileName)
1644
+
1645
+ if (path.resolve(candidateModelPath) !== path.resolve(importedModelPath)) continue
1646
+
1647
+ return this.relativeImportSpecifier({
1648
+ fromFile: frontendModelFilePath,
1649
+ toFile: path.join(frontendModelsDirectory, modelFileName)
1650
+ })
1651
+ }
1652
+ }
1653
+
1654
+ return null
1655
+ }
1656
+
1657
+ /**
1658
+ * Runs generated frontend model file names.
1659
+ * @returns {Set<string>} - Frontend model filenames that this generation run can emit.
1660
+ */
1661
+ generatedFrontendModelFileNames() {
1662
+ /** @type {Set<string>} */
1663
+ const fileNames = new Set()
1664
+
1665
+ for (const backendProject of this.getConfiguration().getBackendProjects()) {
1666
+ const resources = this.resourcesForBackendProject(backendProject)
1667
+
1668
+ for (const resourceModelName of Object.keys(resources)) {
1669
+ const className = inflection.camelize(resourceModelName.replaceAll("-", "_"))
1670
+
1671
+ fileNames.add(`${inflection.dasherize(inflection.underscore(className))}.js`)
1672
+ }
1673
+ }
1674
+
1675
+ return fileNames
1676
+ }
1677
+
1678
+ /**
1679
+ * Runs relative import specifier.
1680
+ * @param {object} args - Arguments.
1681
+ * @param {string} args.fromFile - Source file that will contain the import expression.
1682
+ * @param {string} args.toFile - File being imported.
1683
+ * @returns {string} - Relative import specifier.
1684
+ */
1685
+ relativeImportSpecifier({fromFile, toFile}) {
1686
+ let relativeSpecifier = path.relative(path.dirname(fromFile), toFile).split(path.sep).join("/")
1687
+
1688
+ if (!relativeSpecifier.startsWith(".")) {
1689
+ relativeSpecifier = `./${relativeSpecifier}`
1690
+ }
1691
+
1692
+ return relativeSpecifier
1693
+ }
1694
+
1695
+ /**
1696
+ * Runs file path is within any directory.
1697
+ * @param {object} args - Arguments.
1698
+ * @param {string[]} args.directories - Candidate parent directories.
1699
+ * @param {string} args.filePath - File path to test.
1700
+ * @returns {boolean} - Whether the file path is under one candidate directory.
1701
+ */
1702
+ filePathIsWithinAnyDirectory({directories, filePath}) {
1703
+ return directories.some((directory) => {
1704
+ const relativePath = path.relative(path.resolve(directory), path.resolve(filePath))
1705
+
1706
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
1707
+ })
1708
+ }
1709
+
1710
+ /**
1711
+ * Escapes text for use inside a RegExp.
1712
+ * @param {string} value - Value to escape.
1713
+ * @returns {string} - RegExp-safe value.
1714
+ */
1715
+ escapeRegExp(value) {
1716
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1459
1717
  }
1460
1718
 
1461
1719
  /**
@@ -1474,10 +1732,15 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1474
1732
 
1475
1733
  if (metadata.args.length > 0) {
1476
1734
  const parameterNames = metadata.args.map((arg) => arg.name)
1735
+ // A single args object whose every field is optional accepts `{}`, so default
1736
+ // the parameter and mark it optional — callers can then omit it entirely
1737
+ // (`record.command()` instead of `record.command({})`). Required-field args keep
1738
+ // the mandatory parameter (a `{}` default wouldn't satisfy their type).
1739
+ const defaultsToEmptyObject = metadata.args.length === 1 && this.argTypeAcceptsEmptyObject(metadata.args[0].type)
1477
1740
 
1478
1741
  return {
1479
- paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${arg.name} - Command argument.\n`).join(""),
1480
- parameters: parameterNames.join(", "),
1742
+ paramDocs: metadata.args.map((arg) => ` * @param {${arg.type}} ${defaultsToEmptyObject ? `[${arg.name}]` : arg.name} - Command argument.\n`).join(""),
1743
+ parameters: defaultsToEmptyObject ? `${parameterNames[0]} = {}` : parameterNames.join(", "),
1481
1744
  payloadArguments: `[${parameterNames.join(", ")}]`,
1482
1745
  returnType
1483
1746
  }
@@ -1491,6 +1754,124 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1491
1754
  }
1492
1755
  }
1493
1756
 
1757
+ /**
1758
+ * Whether a single command-args JSDoc type is known to accept an empty object `{}`:
1759
+ * a single balanced object literal whose top-level members are all optional (`name?:`)
1760
+ * or index signatures (`[k: ...]:`). Anything else returns false so the parameter stays
1761
+ * required — including a required member, a non-object-literal (a positional `number`,
1762
+ * a `Record<...>` / `Partial<...>` whose key/wrapper may still require data), and any
1763
+ * intersection/union (e.g. `{a?: x} & {b: string}`), where `{}` is not assignable.
1764
+ * @param {string} type - The arg's JSDoc type string.
1765
+ * @returns {boolean} - Whether the generated parameter can default to `{}`.
1766
+ */
1767
+ argTypeAcceptsEmptyObject(type) {
1768
+ const trimmedType = type.trim()
1769
+
1770
+ // Must be a single balanced object literal: starts with `{`, ends with `}`, and the
1771
+ // opening brace closes only at the final character. This rejects intersections/unions
1772
+ // like `{a?: x} & {b: string}` that merely happen to start `{` and end `}`.
1773
+ if (!(trimmedType.startsWith("{") && trimmedType.endsWith("}"))) return false
1774
+ if (!this.isSingleBalancedObjectLiteral(trimmedType)) return false
1775
+
1776
+ const inner = trimmedType.slice(1, -1)
1777
+
1778
+ for (const member of this.splitTopLevelTypeMembers(inner)) {
1779
+ const colonIndex = this.topLevelColonIndex(member)
1780
+
1781
+ // No top-level colon: a call/construct/mapped signature or malformed member —
1782
+ // can't confirm it's optional, so treat the type as not empty-defaultable.
1783
+ if (colonIndex < 0) return false
1784
+
1785
+ const key = member.slice(0, colonIndex).trim()
1786
+
1787
+ // Index signatures (`[k: string]`) don't require a value; optional props end in `?`.
1788
+ // Anything else is a required property, so `{}` would not satisfy the type.
1789
+ if (!key.startsWith("[") && !key.endsWith("?")) return false
1790
+ }
1791
+
1792
+ return true
1793
+ }
1794
+
1795
+ /**
1796
+ * Splits the inner body of an object-literal type into its top-level members,
1797
+ * respecting nested `{}` / `[]` / `<>` / `()` so field types like `string[] | null`
1798
+ * or `{a: b}` aren't split mid-type. Members are separated by `,` or `;`.
1799
+ * @param {string} inner - Object-literal body (without the outer braces).
1800
+ * @returns {string[]} - Trimmed non-empty top-level members.
1801
+ */
1802
+ splitTopLevelTypeMembers(inner) {
1803
+ const members = []
1804
+ let depth = 0
1805
+ let start = 0
1806
+
1807
+ for (let index = 0; index < inner.length; index += 1) {
1808
+ const character = inner[index]
1809
+
1810
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1811
+ depth += 1
1812
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1813
+ depth -= 1
1814
+ } else if ((character === "," || character === ";") && depth === 0) {
1815
+ members.push(inner.slice(start, index))
1816
+ start = index + 1
1817
+ }
1818
+ }
1819
+
1820
+ members.push(inner.slice(start))
1821
+
1822
+ return members.map((member) => member.trim()).filter((member) => member.length > 0)
1823
+ }
1824
+
1825
+ /**
1826
+ * Index of the first top-level `:` in an object-literal member, ignoring colons
1827
+ * nested inside `{}` / `[]` / `<>` / `()` (e.g. an index signature `[k: string]`).
1828
+ * @param {string} member - A single object-literal member.
1829
+ * @returns {number} - The colon index, or -1 when none is found at the top level.
1830
+ */
1831
+ topLevelColonIndex(member) {
1832
+ let depth = 0
1833
+
1834
+ for (let index = 0; index < member.length; index += 1) {
1835
+ const character = member[index]
1836
+
1837
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1838
+ depth += 1
1839
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1840
+ depth -= 1
1841
+ } else if (character === ":" && depth === 0) {
1842
+ return index
1843
+ }
1844
+ }
1845
+
1846
+ return -1
1847
+ }
1848
+
1849
+ /**
1850
+ * Whether the type is a single balanced object literal — its leading `{` closes only
1851
+ * at the final character. Rejects top-level intersections/unions like `{a?: x} & {b: y}`
1852
+ * or `{a?: x} | string` whose brace depth returns to 0 before the end.
1853
+ * @param {string} type - A trimmed type string that starts with `{` and ends with `}`.
1854
+ * @returns {boolean} - Whether the braces wrap the whole type.
1855
+ */
1856
+ isSingleBalancedObjectLiteral(type) {
1857
+ let depth = 0
1858
+
1859
+ for (let index = 0; index < type.length; index += 1) {
1860
+ const character = type[index]
1861
+
1862
+ if (character === "{" || character === "[" || character === "<" || character === "(") {
1863
+ depth += 1
1864
+ } else if (character === "}" || character === "]" || character === ">" || character === ")") {
1865
+ depth -= 1
1866
+
1867
+ // The opening brace balanced before the end, so something follows the literal.
1868
+ if (depth === 0 && index < type.length - 1) return false
1869
+ }
1870
+ }
1871
+
1872
+ return depth === 0
1873
+ }
1874
+
1494
1875
  /**
1495
1876
  * Enriches custom-command metadata by deriving a command's typed args and return
1496
1877
  * type from the backend resource method's `@param`/`@returns` JSDoc when they are
@@ -1502,10 +1883,11 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1502
1883
  * @param {object} args - Arguments.
1503
1884
  * @param {Record<string, {args: Array<{name: string, type: string}>, returnType: string | null}>} args.commandMetadata - Declared per-command metadata.
1504
1885
  * @param {string[]} args.commandNames - Command method names to resolve.
1886
+ * @param {string} args.frontendModelFilePath - Generated frontend model file path.
1505
1887
  * @param {import("../../../../../configuration-types.js").FrontendModelResourceClassType | null | undefined} args.resourceClass - Resource class.
1506
1888
  * @returns {Promise<Record<string, {args: Array<{name: string, type: string}>, returnType: string | null}>>} - Enriched metadata.
1507
1889
  */
1508
- async commandMetadataWithResourceJsDoc({commandMetadata, commandNames, resourceClass}) {
1890
+ async commandMetadataWithResourceJsDoc({commandMetadata, commandNames, frontendModelFilePath, resourceClass}) {
1509
1891
  if (!resourceClass) return commandMetadata
1510
1892
 
1511
1893
  /** @type {Record<string, {args: Array<{name: string, type: string}>, returnType: string | null}>} */
@@ -1524,10 +1906,15 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1524
1906
  let returnType = declared.returnType
1525
1907
 
1526
1908
  if (!returnType) {
1527
- const jsDocReturnType = await this.resourceMethodReturnType({methodName: commandName, sourceClassName})
1909
+ const jsDocReturnType = await this.resourceMethodReturnTypeDefinition({methodName: commandName, sourceClassName})
1528
1910
 
1529
1911
  if (jsDocReturnType) {
1530
- returnType = this.frontendResolvableCommandJsDocType(this.unwrappedPromiseJsDocType({jsDocType: jsDocReturnType}))
1912
+ returnType = this.frontendResolvableCommandJsDocType({
1913
+ frontendModelFilePath,
1914
+ importAliases: jsDocReturnType.importAliases,
1915
+ jsDocType: this.unwrappedPromiseJsDocType({jsDocType: jsDocReturnType.type}),
1916
+ sourceFile: jsDocReturnType.sourceFile
1917
+ })
1531
1918
  }
1532
1919
  }
1533
1920
 
@@ -1543,7 +1930,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1543
1930
  if (topLevelParameters.length > 0) {
1544
1931
  args = topLevelParameters.map((parameter) => ({
1545
1932
  name: /** @type {string} */ (parameter.name),
1546
- type: this.frontendResolvableCommandJsDocType(parameter.type)
1933
+ type: this.frontendResolvableCommandJsDocType({
1934
+ frontendModelFilePath,
1935
+ importAliases: parameter.importAliases,
1936
+ jsDocType: parameter.type,
1937
+ sourceFile: parameter.sourceFile
1938
+ })
1547
1939
  }))
1548
1940
  }
1549
1941
  }
@@ -1615,6 +2007,19 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1615
2007
  * @returns {Promise<string | null>} - JSDoc return type when documented.
1616
2008
  */
1617
2009
  async resourceMethodReturnType({methodName, sourceClassName}) {
2010
+ const returnType = await this.resourceMethodReturnTypeDefinition({methodName, sourceClassName})
2011
+
2012
+ return returnType ? returnType.type : null
2013
+ }
2014
+
2015
+ /**
2016
+ * Runs resource method return type definition.
2017
+ * @param {object} args - Arguments.
2018
+ * @param {string} args.methodName - Method name.
2019
+ * @param {string} args.sourceClassName - Source class name.
2020
+ * @returns {Promise<ResourceMethodReturnType | null>} - JSDoc return type definition when documented.
2021
+ */
2022
+ async resourceMethodReturnTypeDefinition({methodName, sourceClassName}) {
1618
2023
  const resourceMethodReturnTypes = await this.resourceMethodReturnTypes()
1619
2024
  const returnTypeKey = `${sourceClassName}.${methodName}`
1620
2025
 
@@ -1622,7 +2027,11 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1622
2027
 
1623
2028
  const returnType = resourceMethodReturnTypes.get(returnTypeKey)
1624
2029
 
1625
- if (typeof returnType != "string" || returnType.length < 1) {
2030
+ if (!returnType) {
2031
+ throw new Error(`Expected JSDoc return type for ${returnTypeKey}`)
2032
+ }
2033
+
2034
+ if (typeof returnType.type != "string" || returnType.type.length < 1) {
1626
2035
  throw new Error(`Expected non-empty JSDoc return type for ${returnTypeKey}`)
1627
2036
  }
1628
2037
 
@@ -1653,7 +2062,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1653
2062
  /**
1654
2063
  * Runs resource method parameters.
1655
2064
  * @param {{methodName: string, sourceClassName: string}} args - Arguments.
1656
- * @returns {Promise<Array<{name: string | null, type: string}> | null>} - JSDoc parameters (name + type) when documented.
2065
+ * @returns {Promise<ResourceMethodParameterType[] | null>} - JSDoc parameters (name + type) when documented.
1657
2066
  */
1658
2067
  async resourceMethodParameters({methodName, sourceClassName}) {
1659
2068
  const resourceMethodParameterTypes = await this.resourceMethodParameterTypes()
@@ -1672,7 +2081,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1672
2081
 
1673
2082
  /**
1674
2083
  * Runs resource method return types.
1675
- * @returns {Promise<Map<string, string>>} - Resource method return types keyed by ClassName.methodName.
2084
+ * @returns {Promise<Map<string, ResourceMethodReturnType>>} - Resource method return types keyed by ClassName.methodName.
1676
2085
  */
1677
2086
  async resourceMethodReturnTypes() {
1678
2087
  if (this._resourceMethodReturnTypes) return this._resourceMethodReturnTypes
@@ -1683,7 +2092,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1683
2092
  for (const sourceFile of sourceFiles) {
1684
2093
  const sourceText = await fs.readFile(sourceFile, "utf8")
1685
2094
 
1686
- this.addResourceMethodReturnTypesFromSource({returnTypes, sourceText})
2095
+ this.addResourceMethodReturnTypesFromSource({returnTypes, sourceFile, sourceText})
1687
2096
  }
1688
2097
 
1689
2098
  this._resourceMethodReturnTypes = returnTypes
@@ -1693,7 +2102,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1693
2102
 
1694
2103
  /**
1695
2104
  * Runs resource method parameter types.
1696
- * @returns {Promise<Map<string, Array<{name: string | null, type: string}>>>} - Resource method parameters keyed by ClassName.methodName.
2105
+ * @returns {Promise<Map<string, ResourceMethodParameterType[]>>} - Resource method parameters keyed by ClassName.methodName.
1697
2106
  */
1698
2107
  async resourceMethodParameterTypes() {
1699
2108
  if (this._resourceMethodParameterTypes) return this._resourceMethodParameterTypes
@@ -1704,7 +2113,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1704
2113
  for (const sourceFile of sourceFiles) {
1705
2114
  const sourceText = await fs.readFile(sourceFile, "utf8")
1706
2115
 
1707
- this.addResourceMethodParameterTypesFromSource({parameterTypes, sourceText})
2116
+ this.addResourceMethodParameterTypesFromSource({parameterTypes, sourceFile, sourceText})
1708
2117
  }
1709
2118
 
1710
2119
  this._resourceMethodParameterTypes = parameterTypes
@@ -1745,12 +2154,14 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1745
2154
  /**
1746
2155
  * Adds resource method return types from source.
1747
2156
  * @param {object} args - Arguments.
1748
- * @param {Map<string, string>} args.returnTypes - Mutable return types map.
2157
+ * @param {Map<string, ResourceMethodReturnType>} args.returnTypes - Mutable return types map.
2158
+ * @param {string | null} [args.sourceFile] - Source file path.
1749
2159
  * @param {string} args.sourceText - Source text.
1750
2160
  * @returns {void}
1751
2161
  */
1752
- addResourceMethodReturnTypesFromSource({returnTypes, sourceText}) {
2162
+ addResourceMethodReturnTypesFromSource({returnTypes, sourceFile = null, sourceText}) {
1753
2163
  const classRegex = /class\s+([A-Za-z_$][\w$]*)\s+(?:extends\s+[^{]+)?\{/g
2164
+ const importAliases = this.jsDocImportAliasesFromSource(sourceText)
1754
2165
  let classMatch
1755
2166
 
1756
2167
  while ((classMatch = classRegex.exec(sourceText))) {
@@ -1782,7 +2193,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1782
2193
  const returnType = this.jsDocReturnType(jsDocMatch[1])
1783
2194
 
1784
2195
  if (returnType) {
1785
- returnTypes.set(`${className}.${methodName}`, returnType)
2196
+ returnTypes.set(`${className}.${methodName}`, {importAliases, sourceFile, type: returnType})
1786
2197
  }
1787
2198
  }
1788
2199
 
@@ -1792,11 +2203,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1792
2203
 
1793
2204
  /**
1794
2205
  * Adds resource method parameter types from source.
1795
- * @param {{parameterTypes: Map<string, Array<{name: string | null, type: string}>>, sourceText: string}} args - Arguments.
2206
+ * @param {{parameterTypes: Map<string, ResourceMethodParameterType[]>, sourceFile?: string | null, sourceText: string}} args - Arguments.
1796
2207
  * @returns {void}
1797
2208
  */
1798
- addResourceMethodParameterTypesFromSource({parameterTypes, sourceText}) {
2209
+ addResourceMethodParameterTypesFromSource({parameterTypes, sourceFile = null, sourceText}) {
1799
2210
  const classRegex = /class\s+([A-Za-z_$][\w$]*)\s+(?:extends\s+[^{]+)?\{/g
2211
+ const importAliases = this.jsDocImportAliasesFromSource(sourceText)
1800
2212
  let classMatch
1801
2213
 
1802
2214
  while ((classMatch = classRegex.exec(sourceText))) {
@@ -1827,7 +2239,7 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1827
2239
  const jsDocParameters = this.jsDocParameters(jsDocMatch[1])
1828
2240
 
1829
2241
  if (jsDocParameters.length > 0) {
1830
- parameterTypes.set(`${className}.${methodName}`, jsDocParameters)
2242
+ parameterTypes.set(`${className}.${methodName}`, jsDocParameters.map((parameter) => ({...parameter, importAliases, sourceFile})))
1831
2243
  }
1832
2244
  }
1833
2245
 
@@ -1835,6 +2247,41 @@ export default class DbGenerateFrontendModels extends BaseCommand {
1835
2247
  }
1836
2248
  }
1837
2249
 
2250
+ /**
2251
+ * Runs JSDoc import aliases from source.
2252
+ * @param {string} sourceText - Source text.
2253
+ * @returns {Map<string, ResourceJsDocImportAlias>} - Import aliases keyed by local name.
2254
+ */
2255
+ jsDocImportAliasesFromSource(sourceText) {
2256
+ const importAliases = new Map()
2257
+ const importRegex = /@import\s*\{\s*([^}]+?)\s*\}\s*from\s*["']([^"']+)["']/g
2258
+ let importMatch
2259
+
2260
+ while ((importMatch = importRegex.exec(sourceText))) {
2261
+ const importList = importMatch[1]
2262
+ const specifier = importMatch[2]
2263
+
2264
+ for (const rawImportEntry of importList.split(",")) {
2265
+ const importEntry = rawImportEntry.trim()
2266
+
2267
+ if (importEntry.length < 1) continue
2268
+
2269
+ const entryMatch = importEntry.match(/^(default|[A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/)
2270
+
2271
+ if (!entryMatch) {
2272
+ throw new Error(`Could not parse JSDoc @import entry: ${importEntry}`)
2273
+ }
2274
+
2275
+ const importedName = entryMatch[1]
2276
+ const aliasName = entryMatch[2] || importedName
2277
+
2278
+ importAliases.set(aliasName, {importedName, specifier})
2279
+ }
2280
+ }
2281
+
2282
+ return importAliases
2283
+ }
2284
+
1838
2285
  /**
1839
2286
  * Runs js doc return type.
1840
2287
  * @param {string} jsDocText - JSDoc text inside comment markers.