mxrb 0.1.2 → 0.1.4

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,1361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'monitor'
7
+ require 'uri'
8
+ require_relative 'ruby_app/session_manager'
9
+ require_relative 'http/server'
10
+
11
+ module Mxrb
12
+ # Runtime and reversible metadata contract for projects exported with
13
+ # `mxrb export --mode ruby`.
14
+ module RubyApp
15
+ # rubocop:disable Metrics
16
+ MANIFEST_PATH = File.join('.mxrb', 'ruby-app.json')
17
+ SOURCE_GLOBS = [
18
+ 'app/**/*.rb', 'config/**/*', 'frontend/**/*', 'spec/**/*.rb',
19
+ 'db/migrate/**/*', 'bin/**/*', '.rspec', 'config.ru',
20
+ 'Gemfile', 'Rakefile', 'README.md', '.gitignore', '.env.example'
21
+ ].freeze
22
+ SOURCE_EXCLUSIONS = %w[frontend/node_modules/ frontend/dist/].freeze
23
+ ARTIFACT_DIRECTORIES = %w[models dtos services pages].freeze
24
+
25
+ def self.application_files(root)
26
+ ARTIFACT_DIRECTORIES.flat_map do |directory|
27
+ Dir.glob(File.join(root, 'app', directory, '**', '*.rb'))
28
+ end.sort
29
+ end
30
+
31
+ def self.source_bundle(root)
32
+ expanded = File.expand_path(root)
33
+ candidates = SOURCE_GLOBS.flat_map { Dir.glob(File.join(expanded, _1), File::FNM_DOTMATCH) }
34
+ .select { File.file?(_1) }
35
+ files = candidates.filter_map do |path|
36
+ relative = Pathname.new(path).relative_path_from(Pathname.new(expanded)).cleanpath.to_s
37
+ next if SOURCE_EXCLUSIONS.any? { relative.start_with?(_1) }
38
+
39
+ contents = File.binread(path)
40
+ {
41
+ path: relative, contents:, sha256: Digest::SHA256.hexdigest(contents),
42
+ mode: File.stat(path).mode & 0o777
43
+ }
44
+ end
45
+ files.uniq { _1.fetch(:path) }.sort_by { _1.fetch(:path) }
46
+ end
47
+
48
+ def self.safe_source_path(root, relative)
49
+ clean = Pathname.new(relative.to_s).cleanpath
50
+ raise ValidationError, "unsafe embedded Ruby source path: #{relative}" \
51
+ if clean.absolute? || clean.to_s == '..' || clean.to_s.start_with?('../')
52
+
53
+ File.join(File.expand_path(root), clean.to_s)
54
+ end
55
+
56
+ def self.safe_source_mode(value, relative)
57
+ fallback = relative.to_s.start_with?('bin/') ? 0o755 : 0o644
58
+ mode = Integer(value || fallback)
59
+ raise ValidationError, "unsafe embedded Ruby source mode: #{mode}" unless mode.between?(0, 0o777)
60
+
61
+ mode
62
+ end
63
+
64
+ def self.compile(root, target = nil, mendix_version: nil)
65
+ manifest = Manifest.load(root)
66
+ destination = File.expand_path(
67
+ target || ENV.fetch('MXRB_OUTPUT_PATH', File.join(root, 'build', manifest.mpr_name))
68
+ )
69
+ previous = ENV['MXRB_OUTPUT_PATH']
70
+ ENV['MXRB_OUTPUT_PATH'] = destination
71
+ load manifest.absolute_path('mendix_project')
72
+ transition(destination, mendix_version) if mendix_version
73
+ Synchronizer.new(root, destination, manifest:).synchronize!
74
+ destination
75
+ ensure
76
+ if previous
77
+ ENV['MXRB_OUTPUT_PATH'] = previous
78
+ else
79
+ ENV.delete('MXRB_OUTPUT_PATH')
80
+ end
81
+ end
82
+
83
+ def self.transition(path, version)
84
+ project = Model::Project.open(path, readonly: false)
85
+ project.migrate_to!(version) unless project.mendix_version == version.to_s
86
+ ensure
87
+ project&.close
88
+ end
89
+
90
+ # Reads and validates paths before runtime or reverse compilation uses them.
91
+ class Manifest
92
+ attr_reader :root, :data
93
+
94
+ def self.load(root)
95
+ expanded = File.expand_path(root)
96
+ path = File.join(expanded, MANIFEST_PATH)
97
+ raise ArgumentError, "not an MXRB Ruby application: #{expanded}" unless File.file?(path)
98
+
99
+ new(expanded, JSON.parse(File.read(path)))
100
+ rescue JSON::ParserError => e
101
+ raise ArgumentError, "invalid MXRB Ruby application manifest: #{e.message}"
102
+ end
103
+
104
+ def initialize(root, data)
105
+ @root = root
106
+ @data = data
107
+ raise ArgumentError, 'MXRB Ruby application manifest must use mode ruby' unless data['mode'] == 'ruby'
108
+ end
109
+
110
+ def mpr_name = data.fetch('source').fetch('name')
111
+ def modules = data.fetch('modules')
112
+ def coverage = data.fetch('coverage')
113
+
114
+ def absolute_path(key)
115
+ relative = data.fetch('round_trip').fetch(key)
116
+ clean = Pathname.new(relative).cleanpath
117
+ if clean.absolute? || clean.to_s.start_with?('../')
118
+ raise ArgumentError,
119
+ "unsafe Ruby application path: #{relative}"
120
+ end
121
+
122
+ File.join(root, clean.to_s)
123
+ end
124
+ end
125
+
126
+ # Per-process registry populated by conventional app/**/*.rb files.
127
+ module Registry
128
+ ADAPTER_KINDS = %i[
129
+ app_service web_service import_xml import_mapping export_mapping document
130
+ ].freeze
131
+
132
+ module_function
133
+
134
+ def reset!
135
+ @records = {}
136
+ @services = {}
137
+ @pages = {}
138
+ @adapters = {}
139
+ @java_custom_actions = {}
140
+ end
141
+
142
+ def register(kind, name, implementation)
143
+ collection(kind)[name] = implementation
144
+ end
145
+
146
+ def fetch(kind, name) = collection(kind)[name]
147
+ def all(kind) = collection(kind).dup.freeze
148
+
149
+ def register_adapter(kind, implementation = nil, &block)
150
+ key = kind.to_sym
151
+ raise ArgumentError, "unsupported Ruby adapter #{kind}" unless ADAPTER_KINDS.include?(key)
152
+
153
+ callback = implementation || block
154
+ raise ArgumentError, 'adapter must respond to call' unless callback.respond_to?(:call)
155
+
156
+ register(:adapter, key, callback)
157
+ callback
158
+ end
159
+
160
+ def adapters = all(:adapter)
161
+
162
+ def register_java_custom_action(name, implementation = nil, &block)
163
+ qualified_name = name.to_s
164
+ unless qualified_name.match?(/\A[A-Za-z_]\w*\.[A-Za-z_]\w*\z/)
165
+ raise ArgumentError, "Java Custom Action name must be qualified as Module.Action: #{name}"
166
+ end
167
+
168
+ callback = implementation || block
169
+ raise ArgumentError, 'Java Custom Action adapter must respond to call' unless callback.respond_to?(:call)
170
+
171
+ register(:java_custom_action, qualified_name, callback)
172
+ callback
173
+ end
174
+
175
+ def java_custom_actions = all(:java_custom_action)
176
+
177
+ def collection(kind)
178
+ reset! unless defined?(@records) && @records
179
+ {
180
+ record: @records, service: @services, page: @pages, adapter: @adapters,
181
+ java_custom_action: @java_custom_actions
182
+ }.fetch(kind)
183
+ end
184
+ end
185
+
186
+ # Base for generated persistent models.
187
+ class Record
188
+ LIFECYCLE_EVENTS = Runtime::Native::Store::LIFECYCLE_EVENTS
189
+
190
+ class << self
191
+ attr_reader :mendix_id, :attributes, :persistable, :lifecycle_callbacks
192
+
193
+ def inherited(child)
194
+ super
195
+ child.instance_variable_set(:@attributes, [])
196
+ child.instance_variable_set(:@lifecycle_callbacks, {})
197
+ end
198
+
199
+ def mendix_name(value = nil, id: nil)
200
+ return @mendix_name unless value
201
+
202
+ @mendix_name = value.to_s
203
+ @mendix_id = id.to_s
204
+ Registry.register(:record, @mendix_name, self)
205
+ end
206
+
207
+ def persistence(value) = (@persistable = value == true)
208
+
209
+ def attribute(name, type:, mendix_name:, required: false, default: nil)
210
+ @attributes ||= []
211
+ @attributes << {
212
+ name: name.to_sym, type: type.to_sym,
213
+ mendix_name: mendix_name.to_s, required: required == true,
214
+ default: default
215
+ }
216
+ attr_accessor name
217
+ end
218
+
219
+ def lifecycle(event, method_name = nil, &block)
220
+ event = event.to_sym
221
+ raise ArgumentError, "unknown lifecycle event #{event}" unless LIFECYCLE_EVENTS.include?(event)
222
+ raise ArgumentError, 'callback method or block is required' unless method_name || block
223
+
224
+ @lifecycle_callbacks ||= {}
225
+ (@lifecycle_callbacks[event] ||= []) << (method_name || block)
226
+ end
227
+
228
+ LIFECYCLE_EVENTS.each do |event|
229
+ define_method(event) { |method_name = nil, &block| lifecycle(event, method_name, &block) }
230
+ end
231
+
232
+ def from_native(value)
233
+ values = attributes.to_a.to_h do |attribute|
234
+ [attribute.fetch(:name), value.members[attribute.fetch(:mendix_name)]]
235
+ end
236
+ new(id: value.id, **values).tap { _1.instance_variable_set(:@native_value, value) }
237
+ end
238
+ end
239
+
240
+ attr_reader :id
241
+
242
+ def initialize(id: nil, **values)
243
+ @id = id
244
+ values.each { public_send("#{_1}=", _2) if respond_to?("#{_1}=") }
245
+ end
246
+
247
+ def to_h
248
+ values = self.class.attributes.to_a.to_h { [_1.fetch(:name), public_send(_1.fetch(:name))] }
249
+ { id:, type: self.class.mendix_name, attributes: values }
250
+ end
251
+
252
+ def sync_to_native!
253
+ return self unless @native_value
254
+
255
+ self.class.attributes.to_a.each do |attribute|
256
+ @native_value.members[attribute.fetch(:mendix_name)] = public_send(attribute.fetch(:name))
257
+ end
258
+ self
259
+ end
260
+
261
+ def run_lifecycle_callback(callback)
262
+ callback.is_a?(Proc) ? callback.call(self) : public_send(callback)
263
+ ensure
264
+ sync_to_native!
265
+ end
266
+ end
267
+
268
+ # Explicit non-persistent model. Its generated filename and class always
269
+ # end in `_dto` / `Dto`, avoiding ambiguous `_2` fallbacks.
270
+ class DTO < Record; end
271
+
272
+ # Base for generated services. The default body executes through MXRB's
273
+ # pure-Ruby native interpreter and can be replaced with idiomatic Ruby.
274
+ class Service
275
+ class << self
276
+ attr_reader :mendix_id
277
+
278
+ def mendix_name(value = nil, id: nil)
279
+ return @mendix_name unless value
280
+
281
+ @mendix_name = value.to_s
282
+ @mendix_id = id.to_s
283
+ Registry.register(:service, @mendix_name, self)
284
+ end
285
+ end
286
+
287
+ def initialize(application, context: nil)
288
+ @application = application
289
+ @context = context
290
+ end
291
+
292
+ def call(**arguments) = native_call(arguments)
293
+
294
+ private
295
+
296
+ def native_call(arguments)
297
+ @application.native_call(self.class.mendix_name, arguments, context: @context)
298
+ end
299
+ end
300
+
301
+ # Page metadata remains ordinary Ruby while the browser receives its JSON
302
+ # projection from the integrated backend.
303
+ class Page
304
+ class << self
305
+ attr_reader :mendix_id, :title, :widgets, :appearance_class,
306
+ :appearance_style, :data_source
307
+
308
+ def mendix_name(value = nil, id: nil)
309
+ return @mendix_name unless value
310
+
311
+ @mendix_name = value.to_s
312
+ @mendix_id = id.to_s
313
+ Registry.register(:page, @mendix_name, self)
314
+ end
315
+
316
+ def configure(title:, widgets: [], appearance_class: '', appearance_style: '', data_source: nil)
317
+ @title = title.to_s
318
+ @widgets = widgets.freeze
319
+ @appearance_class = appearance_class.to_s
320
+ @appearance_style = appearance_style.to_s
321
+ @data_source = data_source
322
+ end
323
+ end
324
+ end
325
+
326
+ # Loads generated Ruby classes and provides one shared in-memory backend.
327
+ class Application
328
+ attr_reader :root, :manifest, :environment
329
+
330
+ def initialize(root, environment: nil, process: ENV)
331
+ @root = File.expand_path(root)
332
+ @runtime_monitor = Monitor.new
333
+ @manifest = Manifest.load(@root)
334
+ @environment = if environment.is_a?(Environment)
335
+ environment
336
+ else
337
+ Environment.load(
338
+ environment, root: @root, process:
339
+ )
340
+ end
341
+ Registry.reset!
342
+ load_adapters
343
+ load_application_files
344
+ end
345
+
346
+ def schema(context: nil)
347
+ result = {
348
+ mode: 'ruby', environment: environment.name, project: manifest.data.fetch('project'),
349
+ navigation: manifest.data.fetch('navigation', {}),
350
+ modules: manifest.modules, coverage: manifest.coverage
351
+ }
352
+ context ? secure_schema(result, context) : result
353
+ end
354
+
355
+ def call_service(name, arguments = {}, synchronized_context: nil, context: nil)
356
+ runtime_synchronize do
357
+ implementation = Registry.fetch(:service, name.to_s)
358
+ raise NativeRuntimeError, "microflow #{name} not found" unless implementation || microflow_exists?(name)
359
+
360
+ authorize_document!(:microflow, name, :execute, context)
361
+ normalized = arguments.to_h.transform_values { deserialize(_1, context:) }
362
+ synchronize_context(synchronized_context, context:) if synchronized_context
363
+ next native_call(name, normalized, context:) unless implementation
364
+
365
+ keyword_arguments = normalized.transform_keys(&:to_sym)
366
+ implementation.new(self, context:).call(**keyword_arguments)
367
+ end
368
+ end
369
+
370
+ def invoke_service(name, arguments = nil, context: nil, **keyword_arguments)
371
+ runtime_synchronize do
372
+ arguments = keyword_arguments if arguments.nil?
373
+ bridge.interpreter.clear_effects!
374
+ invocation_arguments = arguments.to_h.dup
375
+ synchronized_context = invocation_arguments.delete('__mxrb_context')
376
+ active_context = synchronize_context(synchronized_context, context:) if synchronized_context
377
+ result = call_service(name, invocation_arguments, context:)
378
+ {
379
+ result: serialize(result, context:), effects: serialize(bridge.interpreter.effects, context:),
380
+ context: serialize(active_context, context:)
381
+ }
382
+ ensure
383
+ release_runtime_cache
384
+ end
385
+ end
386
+
387
+ def rest_routes
388
+ @rest_routes ||= manifest.modules.flat_map do |mod|
389
+ mod.fetch('endpoints', []).flat_map do |endpoint|
390
+ endpoint.fetch('operations', []).map do |operation|
391
+ operation.merge(
392
+ 'service' => endpoint.fetch('name'),
393
+ 'enable_cors' => endpoint.fetch('enable_cors', false),
394
+ 'requires_authentication' => endpoint.fetch('requires_authentication', false)
395
+ )
396
+ end
397
+ end
398
+ end
399
+ end
400
+
401
+ def invoke_rest(route, path_parameters: {}, query: {}, body: nil, context: nil)
402
+ service = service_manifest(route.fetch('microflow'))
403
+ arguments = request_arguments(service, path_parameters, query, body, context:)
404
+ invoke_service(route.fetch('microflow'), arguments, context:).fetch(:result)
405
+ end
406
+
407
+ def native_call(name, arguments = {}, context: nil)
408
+ runtime_synchronize do
409
+ authorize_document!(:microflow, name, :execute, context)
410
+ serialize(
411
+ bridge.interpreter.call(name.to_s, arguments.to_h.transform_keys(&:to_s), context:), context:
412
+ )
413
+ end
414
+ end
415
+
416
+ def records(name, context: nil, association: nil, context_type: nil, context_id: nil)
417
+ runtime_synchronize do
418
+ filter = [association, context_type, context_id]
419
+ if filter.any? && !filter.all? { !_1.to_s.empty? }
420
+ raise ArgumentError,
421
+ 'association filter requires association, context_type, and context_id'
422
+ end
423
+ store = bridge.interpreter.store
424
+ values = if filter.all? { !_1.to_s.empty? }
425
+ parent = store.find(context_type.to_s, context_id.to_s)
426
+ parent ? store.retrieve_association(association.to_s, parent) : []
427
+ else
428
+ store.retrieve(name.to_s)
429
+ end
430
+ values = values.select { _1.entity == name.to_s }
431
+ values = access_control.filter_readable(name.to_s, values, context:) if context
432
+ values.map { serialize(_1, context:) }
433
+ ensure
434
+ release_runtime_cache
435
+ end
436
+ end
437
+
438
+ def record(name, id, context: nil)
439
+ runtime_synchronize do
440
+ value = bridge.interpreter.store.find(name.to_s, id.to_s)
441
+ next unless value
442
+ next unless !context || access_control.entity_allowed?(name, action: :read, context:, record: value)
443
+
444
+ serialize(value, context:)
445
+ end
446
+ end
447
+
448
+ def create_record(name, attributes = nil, context: nil, **keyword_attributes)
449
+ runtime_synchronize do
450
+ attributes = keyword_attributes if attributes.nil?
451
+ authorize_entity!(name, :create, context)
452
+ bridge.interpreter.store.transaction do
453
+ value = bridge.interpreter.store.create(name.to_s)
454
+ attributes.to_h.each do |member, member_value|
455
+ authorize_entity!(name, :write, context, member: member, record: value)
456
+ value.members[member.to_s] = member_value
457
+ end
458
+ bridge.interpreter.store.commit(value)
459
+ serialize(value, context:)
460
+ end
461
+ end
462
+ end
463
+
464
+ def delete_record(name, id, context: nil)
465
+ runtime_synchronize do
466
+ value = bridge.interpreter.store.find(name.to_s, id.to_s)
467
+ next false unless value
468
+
469
+ authorize_entity!(name, :delete, context, record: value)
470
+ bridge.interpreter.store.delete(value)
471
+ true
472
+ end
473
+ end
474
+
475
+ def update_record(name, id, attributes, context: nil)
476
+ runtime_synchronize do
477
+ value = bridge.interpreter.store.find(name.to_s, id.to_s)
478
+ next unless value
479
+
480
+ authorize_entity!(name, :write, context, record: value)
481
+ bridge.interpreter.store.transaction do
482
+ attributes.to_h.each do |member, member_value|
483
+ authorize_entity!(name, :write, context, member: member, record: value)
484
+ value.members[member.to_s] = deserialize(member_value)
485
+ end
486
+ bridge.interpreter.store.commit(value)
487
+ serialize(value, context:)
488
+ end
489
+ end
490
+ end
491
+
492
+ def page(name, context: nil)
493
+ implementation = Registry.fetch(:page, name.to_s)
494
+ return unless implementation
495
+
496
+ authorize_document!(:page, name, :read, context)
497
+
498
+ {
499
+ name: implementation.mendix_name, id: implementation.mendix_id,
500
+ title: implementation.title, widgets: implementation.widgets,
501
+ appearance_class: implementation.appearance_class,
502
+ appearance_style: implementation.appearance_style,
503
+ data_source: implementation.data_source
504
+ }
505
+ end
506
+
507
+ def access_control = (@access_control ||= bridge.access_control)
508
+
509
+ def session_manager
510
+ @session_manager ||= SessionManager.new(
511
+ access_control, users: environment['MXRB_USERS_JSON'],
512
+ tokens: environment['MXRB_AUTH_TOKENS'],
513
+ ttl: environment.fetch('MXRB_SESSION_TTL', '3600'),
514
+ store: shared_store
515
+ )
516
+ end
517
+
518
+ def start_scheduler = bridge.start_scheduler
519
+
520
+ def close
521
+ @bridge&.close
522
+ @shared_store&.close
523
+ @bridge = nil
524
+ @access_control = nil
525
+ @session_manager = nil
526
+ @shared_store = nil
527
+ end
528
+
529
+ private
530
+
531
+ def runtime_synchronize(&block)
532
+ (@runtime_monitor ||= Monitor.new).synchronize(&block)
533
+ end
534
+
535
+ def microflow_exists?(name)
536
+ bridge.project.modules.any? do |mod|
537
+ mod.microflows.any? { "#{mod.name}.#{_1.name}" == name.to_s }
538
+ end
539
+ end
540
+
541
+ def authorize_document!(kind, name, action, context)
542
+ return unless context
543
+
544
+ access_control.authorize!(name.to_s, kind:, action:, context:)
545
+ end
546
+
547
+ def authorize_entity!(name, action, context, member: nil, record: nil)
548
+ return unless context
549
+
550
+ access_control.authorize!(name.to_s, kind: :entity, action:, context:, member:, record:)
551
+ end
552
+
553
+ def secure_schema(schema, context)
554
+ secured = Marshal.load(Marshal.dump(schema))
555
+ allowed_pages = []
556
+ allowed_services = []
557
+ secured[:modules].each do |mod|
558
+ mod['services'] = mod.fetch('services', []).select do |service|
559
+ access_control.microflow_allowed?(service.fetch('name'), context:)
560
+ end
561
+ allowed_services.concat(mod['services'].flat_map { [_1['name'], _1['id']] }.compact)
562
+ mod['pages'] = mod.fetch('pages', []).select do |page|
563
+ access_control.page_allowed?(page.fetch('name'), context:)
564
+ end
565
+ allowed_pages.concat(mod['pages'].flat_map { [_1['name'], _1['id']] }.compact)
566
+ %w[models dtos].each do |key|
567
+ mod[key] = mod.fetch(key, []).select do |entity|
568
+ access_control.entity_allowed?(entity.fetch('name'), action: :read, context:)
569
+ end
570
+ end
571
+ end
572
+ secured[:navigation] = secure_navigation(secured[:navigation], allowed_pages, allowed_services)
573
+ secured
574
+ end
575
+
576
+ def secure_navigation(navigation, pages, services)
577
+ result = navigation.to_h.transform_keys(&:to_sym)
578
+ result[:profiles] = Array(result[:profiles]).map do |profile|
579
+ profile = profile.to_h.transform_keys(&:to_sym)
580
+ profile[:home_page] = nil unless pages.include?(profile[:home_page])
581
+ profile[:sign_in_page] = nil unless pages.include?(profile[:sign_in_page])
582
+ profile[:home_microflow] = nil unless services.include?(profile[:home_microflow])
583
+ profile[:role_homes] = Array(profile[:role_homes]).select do |home|
584
+ page = home[:page] || home['page']
585
+ flow = home[:microflow] || home['microflow']
586
+ (!page || pages.include?(page)) && (!flow || services.include?(flow))
587
+ end
588
+ profile[:items] = secure_navigation_items(profile[:items], pages, services)
589
+ profile
590
+ end
591
+ result
592
+ end
593
+
594
+ def secure_navigation_items(items, pages, services)
595
+ Array(items).filter_map do |item|
596
+ item = item.to_h.transform_keys(&:to_sym)
597
+ children = secure_navigation_items(item[:items], pages, services)
598
+ allowed = (!item[:page] || pages.include?(item[:page])) &&
599
+ (!item[:microflow] || services.include?(item[:microflow]))
600
+ next unless allowed || children.any?
601
+
602
+ item.merge(items: children)
603
+ end
604
+ end
605
+
606
+ def service_manifest(name)
607
+ manifest.modules.flat_map { _1.fetch('services', []) }.find { _1.fetch('name') == name } ||
608
+ raise(NativeRuntimeError, "REST microflow #{name} not found")
609
+ end
610
+
611
+ def request_arguments(service, path_parameters, query, body, context: nil)
612
+ supplied = path_parameters.to_h.merge(query.to_h).transform_keys(&:to_s)
613
+ supplied.merge!(body.transform_keys(&:to_s)) if body.is_a?(Hash)
614
+ parameters = service.fetch('parameters', [])
615
+ unresolved = parameters.reject { |parameter| request_value(supplied, parameter.fetch('name')).first }
616
+ if body && unresolved.one? && !body_matches_parameters?(body, parameters)
617
+ supplied[unresolved.first.fetch('name')] = body
618
+ end
619
+ parameters.to_h do |parameter|
620
+ found, value = request_value(supplied, parameter.fetch('name'))
621
+ if !found && parameter['required']
622
+ raise NativeRuntimeError, "missing REST argument #{parameter.fetch('name')}"
623
+ end
624
+
625
+ [parameter.fetch('name'), deserialize_rest_value(value, parameter, context:)]
626
+ end
627
+ end
628
+
629
+ def request_value(values, name)
630
+ key = values.keys.find { _1.casecmp?(name.to_s) }
631
+ [!key.nil?, key && values[key]]
632
+ end
633
+
634
+ def body_matches_parameters?(body, parameters)
635
+ body.is_a?(Hash) && body.keys.any? do |key|
636
+ parameters.any? { _1.fetch('name').casecmp?(key.to_s) }
637
+ end
638
+ end
639
+
640
+ def deserialize_rest_value(value, parameter, context: nil)
641
+ entity = parameter['entity']
642
+ return deserialize(value) unless entity && value.is_a?(Hash) && !(value['id'] && value['type'])
643
+
644
+ authorize_entity!(entity, :create, context)
645
+ object = Runtime::Native::ObjectValue.new(entity:, id: SecureRandom.uuid, members: {})
646
+ value.each do |member, member_value|
647
+ authorize_entity!(entity, :write, context, member:, record: object)
648
+ object.members[member.to_s] = deserialize(member_value, context:)
649
+ end
650
+ object
651
+ end
652
+
653
+ def load_application_files
654
+ RubyApp.application_files(root).each { load _1, true }
655
+ end
656
+
657
+ def load_adapters
658
+ path = File.join(root, 'config', 'adapters.rb')
659
+ load path, true if File.file?(path)
660
+ end
661
+
662
+ def bridge
663
+ @bridge ||= NativeBridge.new(
664
+ manifest.absolute_path('runtime_mpr'),
665
+ database: runtime_database_path,
666
+ record_hooks: Registry.all(:record), adapters: Registry.adapters,
667
+ java_custom_actions: Registry.java_custom_actions,
668
+ allow_destructive: environment['MXRB_ALLOW_DESTRUCTIVE_MIGRATIONS'].to_s.casecmp?('true'),
669
+ coordinator: shared_store,
670
+ scheduler_lease_ttl: environment.fetch('MXRB_SCHEDULER_LEASE_TTL', '300')
671
+ )
672
+ end
673
+
674
+ def shared_store
675
+ @shared_store ||= begin
676
+ configured = environment['MXRB_SHARED_STORE_PATH'].to_s.strip
677
+ if %w[:memory: memory local].include?(configured.downcase)
678
+ Runtime::MemorySharedStore.new
679
+ else
680
+ path = configured.empty? ? default_shared_store_path : File.expand_path(configured, root)
681
+ Runtime::SQLiteSharedStore.new(path)
682
+ end
683
+ end
684
+ end
685
+
686
+ def default_shared_store_path
687
+ File.join(root, '.mxrb', 'runtime', "#{environment.name}-shared.sqlite3")
688
+ end
689
+
690
+ def runtime_database_path
691
+ configured = environment['MXRB_DATABASE_PATH'].to_s
692
+ return File.join(root, '.mxrb', 'runtime', "#{environment.name}.sqlite3") if configured.empty?
693
+
694
+ File.expand_path(configured, root)
695
+ end
696
+
697
+ def serialize(value, seen = {}, context: nil)
698
+ case value
699
+ when Runtime::Native::ObjectValue
700
+ key = [value.entity, value.id]
701
+ return { id: value.id, type: value.entity } if seen[key]
702
+
703
+ branch = seen.merge(key => true)
704
+ members = value.members
705
+ if context
706
+ members = members.select do |member, _member_value|
707
+ access_control.member_allowed?(
708
+ value.entity, member, action: :read, context:, record: value
709
+ )
710
+ end
711
+ end
712
+ { id: value.id, type: value.entity, attributes: serialize(members, branch, context:) }
713
+ when Hash then value.to_h { [serialize(_1, seen, context:), serialize(_2, seen, context:)] }
714
+ when Array then value.map { serialize(_1, seen, context:) }
715
+ else value
716
+ end
717
+ end
718
+
719
+ def deserialize(value = nil, context: nil, synchronize: false, **keyword_value)
720
+ value = keyword_value if value.nil? && !keyword_value.empty?
721
+ if value.is_a?(Hash) && value['id'] && value['type']
722
+ object = bridge.interpreter.store.find(value['type'].to_s, value['id'].to_s)
723
+ raise NativeRuntimeError, "object #{value['type']} #{value['id']} not found" unless object
724
+
725
+ value.fetch('attributes', {}).each do |member, member_value|
726
+ next unless synchronize
727
+
728
+ authorize_entity!(object.entity, :write, context, member:, record: object)
729
+ object.members[member.to_s] = deserialize(member_value, context:, synchronize:)
730
+ end
731
+ object
732
+ elsif value.is_a?(Hash)
733
+ value.to_h do
734
+ [deserialize(_1, context:, synchronize:), deserialize(_2, context:, synchronize:)]
735
+ end
736
+ elsif value.is_a?(Array)
737
+ value.map { deserialize(_1, context:, synchronize:) }
738
+ else
739
+ value
740
+ end
741
+ end
742
+
743
+ def synchronize_context(value, context:)
744
+ store = bridge.interpreter.store
745
+ object = deserialize(value, context:)
746
+ return object unless object.is_a?(Runtime::Native::ObjectValue) && value.is_a?(Hash)
747
+
748
+ changes = value.fetch('attributes', {}).filter_map do |member, member_value|
749
+ resolved = deserialize(member_value, context:)
750
+ [member.to_s, resolved] unless context_values_equal?(object.members[member.to_s], resolved)
751
+ end
752
+ return object if changes.empty?
753
+
754
+ apply = lambda do
755
+ changes.each do |member, member_value|
756
+ authorize_entity!(object.entity, :write, context, member:, record: object)
757
+ object.members[member] = member_value
758
+ end
759
+ store.commit(object) if store.respond_to?(:commit)
760
+ object
761
+ end
762
+ return apply.call unless store.respond_to?(:transaction)
763
+
764
+ store.transaction { apply.call }
765
+ end
766
+
767
+ def context_values_equal?(current, incoming)
768
+ if current.is_a?(Runtime::Native::ObjectValue) || incoming.is_a?(Runtime::Native::ObjectValue)
769
+ return current.is_a?(Runtime::Native::ObjectValue) &&
770
+ incoming.is_a?(Runtime::Native::ObjectValue) &&
771
+ current.entity == incoming.entity && current.id == incoming.id
772
+ end
773
+ if current.is_a?(Array) || incoming.is_a?(Array)
774
+ return false unless current.is_a?(Array) && incoming.is_a?(Array) && current.size == incoming.size
775
+
776
+ return current.zip(incoming).all? { context_values_equal?(_1, _2) }
777
+ end
778
+ if current.respond_to?(:iso8601) && incoming.is_a?(String)
779
+ return Time.parse(incoming).to_i == Time.parse(current.iso8601).to_i
780
+ end
781
+
782
+ current == incoming
783
+ rescue ArgumentError
784
+ false
785
+ end
786
+
787
+ def release_runtime_cache
788
+ store = bridge.interpreter.store
789
+ store.release_cache! if store.respond_to?(:release_cache!)
790
+ end
791
+ end
792
+
793
+ # Owns the source MPR and its pure-Ruby interpreter for one application.
794
+ class NativeBridge
795
+ attr_reader :access_control, :interpreter, :project, :scheduler, :store
796
+
797
+ def initialize(path, database:, record_hooks: {}, adapters: {}, java_custom_actions: {},
798
+ allow_destructive: false, coordinator: nil, scheduler_lease_ttl: 300)
799
+ FileUtils.mkdir_p(File.dirname(database))
800
+ @project = Model::Project.open(path)
801
+ @store = Runtime::SQLiteStore.new(@project, path: database, allow_destructive:)
802
+ @access_control = Runtime::AccessControl.new(@project)
803
+ @interpreter = Runtime::Native::Interpreter.new(
804
+ @project, store: @store, policy: @access_control, adapters:, java_custom_actions:
805
+ )
806
+ register_record_hooks(record_hooks)
807
+ @scheduler = Runtime::Scheduler.new(
808
+ @project,
809
+ executor: ->(name, **_metadata) { @interpreter.call(name) }, coordinator:,
810
+ lease_ttl: scheduler_lease_ttl
811
+ )
812
+ rescue StandardError
813
+ close
814
+ raise
815
+ end
816
+
817
+ def start_scheduler
818
+ scheduler.start unless scheduler.jobs.empty?
819
+ scheduler
820
+ end
821
+
822
+ def close
823
+ scheduler&.shutdown
824
+ store&.close
825
+ project&.close
826
+ end
827
+
828
+ private
829
+
830
+ def register_record_hooks(records)
831
+ records.each do |entity, implementation|
832
+ implementation.lifecycle_callbacks.to_h.each do |event, callbacks|
833
+ callbacks.each do |callback|
834
+ store.on(entity, event) do |value|
835
+ implementation.from_native(value).run_lifecycle_callback(callback)
836
+ end
837
+ end
838
+ end
839
+ end
840
+ end
841
+ end
842
+
843
+ # Applies the reversible Ruby domain contract to a generated MPR. Anything
844
+ # outside this contract remains in the native sidecar and is never dropped.
845
+ class Synchronizer
846
+ def initialize(root, target, manifest: Manifest.load(root))
847
+ @root = File.expand_path(root)
848
+ @target = File.expand_path(target)
849
+ @manifest = manifest
850
+ end
851
+
852
+ def synchronize!
853
+ Registry.reset!
854
+ RubyApp.application_files(@root).each { load _1, true }
855
+ project = Model::Project.open(@target, readonly: false)
856
+ synchronize_entities(project)
857
+ project.close
858
+ project = nil
859
+ embed_sources!
860
+ @target
861
+ ensure
862
+ project&.close
863
+ end
864
+
865
+ private
866
+
867
+ def embed_sources!
868
+ files = RubyApp.source_bundle(@root)
869
+ mpr = IO::MprFile.open(@target, readonly: false)
870
+ mpr.transaction { mpr.write_ruby_app_sources(files) }
871
+ ensure
872
+ mpr&.close
873
+ end
874
+
875
+ def synchronize_entities(project)
876
+ source_entities = @manifest.modules.flat_map do |mod|
877
+ mod.fetch('models') + mod.fetch('dtos')
878
+ end
879
+ source_names = source_entities.map { _1.fetch('name') }
880
+ registered = Registry.all(:record)
881
+
882
+ (source_names - registered.keys).each do |name|
883
+ plan = project.plan_remove_entity(name)
884
+ raise ValidationError, plan.changes.join('; ') unless plan.safe?
885
+
886
+ plan.apply!
887
+ end
888
+ (registered.keys - source_names).each { add_entity(project, _1, registered.fetch(_1)) }
889
+ (source_names & registered.keys).each { synchronize_entity(project, _1, registered.fetch(_1)) }
890
+ end
891
+
892
+ def add_entity(project, name, implementation)
893
+ module_name, entity_name = qualified_parts(name)
894
+ attributes = implementation.attributes.to_a.map do |attribute|
895
+ reject_unsupported_required!(name, attribute)
896
+ {
897
+ name: attribute.fetch(:mendix_name), type: attribute.fetch(:type),
898
+ default: attribute[:default]
899
+ }
900
+ end
901
+ project.plan_add_entity(
902
+ module_name, name: entity_name, attributes:,
903
+ non_persistent: implementation.persistable == false
904
+ ).apply!
905
+ end
906
+
907
+ def synchronize_entity(project, name, implementation)
908
+ artifact = project.find_artifact(name, kind: :entity)
909
+ raise ValidationError, "entity #{name} is missing from generated MPR" unless artifact
910
+
911
+ model = artifact.metadata.fetch(:model)
912
+ expected_persistence = implementation.persistable != false
913
+ if model.persistable != expected_persistence
914
+ raise ValidationError,
915
+ "changing persistence for #{name} is outside the reversible Ruby contract"
916
+ end
917
+ synchronize_attributes(project, name, model, implementation.attributes.to_a)
918
+ end
919
+
920
+ def synchronize_attributes(project, entity_name, entity, declarations)
921
+ declared = declarations.to_h { [_1.fetch(:mendix_name), _1] }
922
+ existing = entity.attributes.to_h { [_1.name, _1] }
923
+ (existing.keys - declared.keys).each do |name|
924
+ plan = project.plan_remove_attribute("#{entity_name}/#{name}")
925
+ raise ValidationError, plan.changes.join('; ') unless plan.safe?
926
+
927
+ plan.apply!
928
+ end
929
+ (declared.keys - existing.keys).each do |name|
930
+ declaration = declared.fetch(name)
931
+ reject_unsupported_required!(entity_name, declaration)
932
+ project.plan_add_attribute(
933
+ entity_name, name:, type: declaration.fetch(:type),
934
+ default: declaration[:default]
935
+ ).apply!
936
+ end
937
+ (declared.keys & existing.keys).each do |name|
938
+ synchronize_attribute(project, entity_name, existing.fetch(name), declared.fetch(name))
939
+ end
940
+ end
941
+
942
+ def synchronize_attribute(project, entity_name, attribute, declaration)
943
+ if (attribute.required == true) != declaration.fetch(:required)
944
+ raise ValidationError,
945
+ "changing required for #{entity_name}/#{attribute.name} is outside the reversible Ruby contract"
946
+ end
947
+ updates = {}
948
+ declared_type = declaration.fetch(:type).to_sym
949
+ updates[:type] = declared_type if attribute.type != declared_type
950
+ current_default = attribute.default_value.to_s
951
+ declared_default = declaration[:default].to_s
952
+ updates[:default] = declaration[:default] if current_default != declared_default
953
+ return if updates.empty?
954
+
955
+ project.plan_change_attribute("#{entity_name}/#{attribute.name}", **updates).apply!
956
+ end
957
+
958
+ def reject_unsupported_required!(entity_name, declaration)
959
+ return unless declaration.fetch(:required)
960
+
961
+ raise ValidationError,
962
+ "adding required attribute #{entity_name}/#{declaration.fetch(:mendix_name)} " \
963
+ 'is outside the reversible Ruby contract'
964
+ end
965
+
966
+ def qualified_parts(name)
967
+ parts = name.to_s.split('.', 2)
968
+ raise ValidationError, "Ruby model name must be Module.Entity: #{name}" unless parts.size == 2
969
+
970
+ parts
971
+ end
972
+ end
973
+
974
+ # Loopback HTTP server for the generated backend and browser frontend.
975
+ class Server
976
+ MAX_BODY_BYTES = 1_048_576
977
+ LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
978
+
979
+ attr_reader :host, :port, :application
980
+
981
+ def initialize(root, host: '127.0.0.1', port: 9292, logger: nil, environment: nil)
982
+ @host = host.to_s
983
+ raise ArgumentError, 'the Ruby application server must bind to loopback' unless LOOPBACK_HOSTS.include?(@host)
984
+
985
+ @port = Integer(port)
986
+ @application = Application.new(root, environment:)
987
+ @sessions = @application.session_manager
988
+ @logger = logger
989
+ end
990
+
991
+ def start
992
+ @server = Http::Server.new(host:, port:, logger: @logger) { dispatch(_1, _2) }
993
+ application.start_scheduler
994
+ @server.start { yield(_1) if block_given? }
995
+ ensure
996
+ application.close
997
+ end
998
+
999
+ def shutdown = @server&.shutdown
1000
+
1001
+ private
1002
+
1003
+ def dispatch(request, response)
1004
+ path = request.path
1005
+ method = request.request_method
1006
+ if method == 'GET' && path == '/api/health'
1007
+ return render_json(
1008
+ response, 200, ok: true, project: application.schema[:project],
1009
+ environment: application.environment.name
1010
+ )
1011
+ end
1012
+
1013
+ if method == 'POST' && path == '/api/login'
1014
+ credentials = request_json(request)
1015
+ session = @sessions.login(credentials['username'], credentials['password'])
1016
+ return render_json(response, 200, { ok: true }.merge(session))
1017
+ end
1018
+
1019
+ context = @sessions.authenticate(request['Authorization'])
1020
+ if method == 'GET' && path == '/api/session'
1021
+ return render_json(
1022
+ response, 200,
1023
+ user: context.user, roles: context.user_roles, module_roles: context.module_roles
1024
+ )
1025
+ end
1026
+ if method == 'POST' && path == '/api/logout'
1027
+ return render_json(response, 200, ok: @sessions.logout(request['Authorization']))
1028
+ end
1029
+ return render_json(response, 200, application.schema(context:)) if method == 'GET' && path == '/api/schema'
1030
+ return render_json(response, 200, application.schema(context:)[:navigation]) \
1031
+ if method == 'GET' && path == '/api/navigation'
1032
+ if method == 'GET' && path == '/api/pages'
1033
+ return render_json(
1034
+ response, 200,
1035
+ pages: application.schema(context:)[:modules].flat_map { _1.fetch('pages') }
1036
+ )
1037
+ end
1038
+
1039
+ if (name = route_name(path, '/api/pages/')) && method == 'GET'
1040
+ page = application.page(name, context:)
1041
+ return render_json(response, page ? 200 : 404, page || error('not_found', "page #{name} not found"))
1042
+ end
1043
+ if (name = route_name(path, '/api/microflows/')) && method == 'POST'
1044
+ invocation = application.invoke_service(name, request_json(request), context:)
1045
+ return render_json(response, 200, { ok: true }.merge(invocation))
1046
+ end
1047
+
1048
+ if (tail = route_name(path, '/api/entities/'))
1049
+ name, id = tail.split('/', 2)
1050
+ if method == 'GET' && id.nil?
1051
+ query = request.query.to_h
1052
+ records = application.records(
1053
+ name, context:,
1054
+ association: query['association'],
1055
+ context_type: query['context_type'],
1056
+ context_id: query['context_id']
1057
+ )
1058
+ return render_json(response, 200, records:)
1059
+ end
1060
+
1061
+ if method == 'GET' && id
1062
+ record = application.record(name, id, context:)
1063
+ return render_json(
1064
+ response, record ? 200 : 404,
1065
+ record || error('not_found', "#{name} #{id} not found")
1066
+ )
1067
+ end
1068
+ return render_json(response, 201, application.create_record(name, request_json(request), context:)) \
1069
+ if method == 'POST' && id.nil?
1070
+
1071
+ if %w[PUT PATCH].include?(method) && id
1072
+ record = application.update_record(name, id, request_json(request), context:)
1073
+ return render_json(
1074
+ response, record ? 200 : 404,
1075
+ record || error('not_found', "#{name} #{id} not found")
1076
+ )
1077
+ end
1078
+
1079
+ if method == 'DELETE' && id
1080
+ deleted = application.delete_record(name, id, context:)
1081
+ return render_json(response, deleted ? 200 : 404, ok: deleted)
1082
+ end
1083
+ end
1084
+
1085
+ if (match = rest_route(method, path))
1086
+ route, path_parameters = match
1087
+ return render_json(response, 401, error('unauthorized', 'Authorization header required')) \
1088
+ if route['requires_authentication'] && request['Authorization'].to_s.empty?
1089
+
1090
+ body = request.body.to_s.empty? ? nil : request_json(request)
1091
+ result = application.invoke_rest(
1092
+ route, path_parameters:, query: request.query.to_h, body:, context:
1093
+ )
1094
+ cors(response) if route['enable_cors']
1095
+ status = Integer(route.fetch('success_status', 200))
1096
+ if status == 204
1097
+ response.status = status
1098
+ response.body = ''
1099
+ return
1100
+ end
1101
+ return render_json(response, status, result)
1102
+ end
1103
+
1104
+ if method == 'OPTIONS' && application.rest_routes.any? { path_match(_1.fetch('path'), path) }
1105
+ cors(response)
1106
+ response.status = 204
1107
+ return
1108
+ end
1109
+
1110
+ return render_static(request, response) if method == 'GET' && !path.start_with?('/api/')
1111
+
1112
+ render_json(response, 404, error('not_found', "route #{method} #{path} not found"))
1113
+ rescue JSON::ParserError, ArgumentError => e
1114
+ render_json(response, 400, error('invalid_request', e.message))
1115
+ rescue AuthenticationError => e
1116
+ render_json(response, 401, error('unauthorized', e.message))
1117
+ rescue Runtime::AuthorizationError => e
1118
+ render_json(response, 403, error('forbidden', e.message))
1119
+ rescue NativeRuntimeError => e
1120
+ render_json(response, 422, error('runtime_error', e.message))
1121
+ end
1122
+
1123
+ def rest_route(method, path)
1124
+ application.rest_routes.each do |route|
1125
+ next unless route.fetch('method') == method
1126
+
1127
+ match = path_match(route.fetch('path'), path)
1128
+ return [route, match.named_captures] if match
1129
+ end
1130
+ nil
1131
+ end
1132
+
1133
+ def path_match(template, path)
1134
+ names = []
1135
+ pattern = Regexp.escape(template).gsub(/\\\{([A-Za-z_]\w*)\\\}/) do
1136
+ names << Regexp.last_match(1)
1137
+ '([^/]+)'
1138
+ end
1139
+ match = %r{\A#{pattern}/?\z}.match(path)
1140
+ return unless match
1141
+
1142
+ values = names.zip(match.captures.map { URI.decode_www_form_component(_1) }).to_h
1143
+ Struct.new(:named_captures).new(values)
1144
+ end
1145
+
1146
+ def cors(response)
1147
+ response['Access-Control-Allow-Origin'] = '*'
1148
+ response['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
1149
+ response['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
1150
+ end
1151
+
1152
+ def route_name(path, prefix)
1153
+ return unless path.start_with?(prefix)
1154
+
1155
+ URI.decode_www_form_component(path.delete_prefix(prefix))
1156
+ end
1157
+
1158
+ def request_json(request)
1159
+ body = request.body.to_s
1160
+ raise ArgumentError, 'request body exceeds 1 MiB' if body.bytesize > MAX_BODY_BYTES
1161
+ return {} if body.empty?
1162
+
1163
+ JSON.parse(body).tap do |payload|
1164
+ raise ArgumentError, 'JSON body must be an object' unless payload.is_a?(Hash)
1165
+ end
1166
+ end
1167
+
1168
+ def render_static(request, response)
1169
+ relative = request.path == '/' ? 'index.html' : request.path.delete_prefix('/')
1170
+ clean = Pathname.new(relative).cleanpath
1171
+ return render_json(response, 404, error('not_found', 'asset not found')) \
1172
+ if clean.absolute? || clean.to_s.start_with?('../')
1173
+
1174
+ public_root = File.join(application.root, 'frontend', 'dist')
1175
+ path = File.join(public_root, clean.to_s)
1176
+ path = File.join(public_root, 'index.html') unless File.file?(path)
1177
+ response.status = File.file?(path) ? 200 : 404
1178
+ response['Content-Type'] = content_type(path)
1179
+ response.body = File.file?(path) ? File.binread(path) : 'Not found'
1180
+ end
1181
+
1182
+ def content_type(path)
1183
+ case File.extname(path)
1184
+ when '.html' then 'text/html; charset=utf-8'
1185
+ when '.js' then 'text/javascript; charset=utf-8'
1186
+ when '.css' then 'text/css; charset=utf-8'
1187
+ when '.json' then 'application/json; charset=utf-8'
1188
+ else 'application/octet-stream'
1189
+ end
1190
+ end
1191
+
1192
+ def error(code, message) = { ok: false, error: { code:, message: } }
1193
+
1194
+ def render_json(response, status, payload)
1195
+ response.status = status
1196
+ response['Content-Type'] = 'application/json; charset=utf-8'
1197
+ response.body = JSON.generate(payload)
1198
+ end
1199
+ end
1200
+
1201
+ # Exposes the same generic MXRB backend through the Rack protocol. Puma,
1202
+ # Sinatra and Rails presets mount this adapter instead of duplicating API
1203
+ # behavior or introducing a second application state.
1204
+ class RackAdapter
1205
+ Request = Struct.new(:path, :request_method, :body, :query, :headers) do
1206
+ def [](name) = headers[name]
1207
+ end
1208
+
1209
+ # Minimal mutable response consumed by Server#dispatch.
1210
+ class Response
1211
+ attr_accessor :status, :body
1212
+ attr_reader :headers
1213
+
1214
+ def initialize
1215
+ @status = 200
1216
+ @body = ''
1217
+ @headers = {}
1218
+ end
1219
+
1220
+ def [](name) = headers[name]
1221
+
1222
+ def []=(name, value)
1223
+ headers[name] = value
1224
+ end
1225
+ end
1226
+
1227
+ attr_reader :root
1228
+
1229
+ def initialize(root)
1230
+ @root = File.expand_path(root)
1231
+ @server_mutex = Mutex.new
1232
+ end
1233
+
1234
+ def server
1235
+ @server || @server_mutex.synchronize { @server ||= Server.new(root, port: 0) }
1236
+ end
1237
+
1238
+ def call(environment)
1239
+ request = rack_request(environment)
1240
+ response = Response.new
1241
+ server.send(:dispatch, request, response)
1242
+ [response.status, response.headers, [response.body.to_s]]
1243
+ end
1244
+
1245
+ def close = @server&.application&.close
1246
+
1247
+ private
1248
+
1249
+ def rack_request(environment)
1250
+ input = environment['rack.input']
1251
+ body = input ? input.read.to_s : ''
1252
+ input.rewind if input.respond_to?(:rewind)
1253
+ query = URI.decode_www_form(environment['QUERY_STRING'].to_s).to_h
1254
+ headers = { 'Authorization' => environment['HTTP_AUTHORIZATION'].to_s }
1255
+ path = "#{environment['SCRIPT_NAME']}#{environment.fetch('PATH_INFO', '/')}"
1256
+ Request.new(
1257
+ path, environment.fetch('REQUEST_METHOD', 'GET'),
1258
+ body, query, headers
1259
+ )
1260
+ end
1261
+ end
1262
+
1263
+ # Starts the Ruby API and Vite dev server as one supervised command.
1264
+ class Supervisor
1265
+ attr_reader :root, :host, :api_port, :frontend_port
1266
+
1267
+ def initialize(root, host: '127.0.0.1', api_port: 9292, frontend_port: 5173,
1268
+ npm: ENV.fetch('MXRB_NPM', 'npm'), frontend: true, environment: nil)
1269
+ @root = File.expand_path(root)
1270
+ @host = host.to_s
1271
+ @api_port = Integer(api_port)
1272
+ @frontend_port = Integer(frontend_port)
1273
+ @npm = npm.to_s
1274
+ @frontend = frontend
1275
+ @environment = if environment.is_a?(Environment)
1276
+ environment
1277
+ else
1278
+ Environment.load(
1279
+ environment, root: @root
1280
+ )
1281
+ end
1282
+ end
1283
+
1284
+ def start
1285
+ if external_backend?
1286
+ @backend_pid = spawn_backend
1287
+ else
1288
+ @server = Server.new(root, host:, port: api_port, environment: @environment)
1289
+ @backend = Thread.new { @server.start }
1290
+ end
1291
+ @frontend_pid = spawn_frontend if @frontend
1292
+ yield(self) if block_given?
1293
+ if @frontend_pid
1294
+ Process.wait(@frontend_pid)
1295
+ elsif @backend_pid
1296
+ Process.wait(@backend_pid)
1297
+ else
1298
+ @backend.join
1299
+ end
1300
+ rescue Interrupt, Errno::ECHILD
1301
+ nil
1302
+ ensure
1303
+ shutdown
1304
+ end
1305
+
1306
+ def shutdown
1307
+ @server&.shutdown
1308
+ terminate(@frontend_pid)
1309
+ terminate(@backend_pid)
1310
+ @backend&.join(2)
1311
+ rescue Errno::ESRCH, Errno::ECHILD
1312
+ nil
1313
+ end
1314
+
1315
+ private
1316
+
1317
+ def external_backend?
1318
+ %w[flymetothemoon onrails].include?(
1319
+ Manifest.load(root).data.dig('ruby_stack', 'preset')
1320
+ )
1321
+ end
1322
+
1323
+ def spawn_backend
1324
+ Process.spawn(
1325
+ profile_environment.merge('HOST' => host, 'MXRB_SERVER_PORT' => api_port.to_s),
1326
+ 'bundle', 'exec', 'puma', '-C', 'config/puma.rb', chdir: root
1327
+ )
1328
+ end
1329
+
1330
+ def terminate(pid)
1331
+ return unless pid
1332
+
1333
+ Process.kill('TERM', pid)
1334
+ Process.wait(pid)
1335
+ end
1336
+
1337
+ def spawn_frontend
1338
+ directory = File.join(root, 'frontend')
1339
+ package = File.join(directory, 'package.json')
1340
+ raise ArgumentError, "frontend package not found: #{directory}" unless File.file?(package)
1341
+ unless File.directory?(File.join(directory, 'node_modules'))
1342
+ raise ArgumentError, "frontend dependencies missing; run `#{@npm} install --prefix frontend`"
1343
+ end
1344
+
1345
+ frontend_environment = profile_environment.select { _1.start_with?('VITE_') }
1346
+ frontend_environment['MXRB_ENV'] = @environment.name
1347
+ frontend_environment['MXRB_API_PORT'] = api_port.to_s
1348
+ Process.spawn(
1349
+ frontend_environment, @npm, 'run', 'dev', '--',
1350
+ '--host', host, '--port', frontend_port.to_s, chdir: directory
1351
+ )
1352
+ end
1353
+
1354
+ def profile_environment
1355
+ @environment.to_h.reject { |key, value| ENV[key] == value }
1356
+ .merge('MXRB_ENV' => @environment.name)
1357
+ end
1358
+ end
1359
+ # rubocop:enable Metrics
1360
+ end
1361
+ end