mxrb 0.1.0

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 (237) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.de-DE.md +108 -0
  4. data/README.md +172 -0
  5. data/README.pt-BR.md +109 -0
  6. data/bin/mxrb +1613 -0
  7. data/docs/README.md +10 -0
  8. data/docs/de-DE/README.md +24 -0
  9. data/docs/de-DE/architectural-patterns.md +26 -0
  10. data/docs/de-DE/architectural-standard.md +48 -0
  11. data/docs/de-DE/architecture.md +76 -0
  12. data/docs/de-DE/compiler.md +162 -0
  13. data/docs/de-DE/conventions.md +19 -0
  14. data/docs/de-DE/design-system.md +52 -0
  15. data/docs/de-DE/entity-dsl.md +53 -0
  16. data/docs/de-DE/native-runtime-quality-report.md +74 -0
  17. data/docs/de-DE/oql-sql.md +211 -0
  18. data/docs/de-DE/platform-api-integration.md +36 -0
  19. data/docs/de-DE/platform-operations.md +243 -0
  20. data/docs/de-DE/project-structure.md +32 -0
  21. data/docs/de-DE/ruby-first-roadmap.md +116 -0
  22. data/docs/de-DE/scaffolds.md +59 -0
  23. data/docs/de-DE/semantic-refactoring.md +56 -0
  24. data/docs/de-DE/team-server.md +33 -0
  25. data/docs/de-DE/validation-matrix.md +64 -0
  26. data/docs/de-DE/vetclinic-acceptance.md +24 -0
  27. data/docs/de-DE/writing.md +138 -0
  28. data/docs/en-US/README.md +24 -0
  29. data/docs/en-US/architectural-patterns.md +151 -0
  30. data/docs/en-US/architectural-standard.md +89 -0
  31. data/docs/en-US/architecture.md +170 -0
  32. data/docs/en-US/compiler.md +157 -0
  33. data/docs/en-US/conventions.md +82 -0
  34. data/docs/en-US/design-system.md +163 -0
  35. data/docs/en-US/entity-dsl.md +53 -0
  36. data/docs/en-US/native-runtime-quality-report.md +73 -0
  37. data/docs/en-US/oql-sql.md +201 -0
  38. data/docs/en-US/platform-api-integration.md +36 -0
  39. data/docs/en-US/platform-operations.md +244 -0
  40. data/docs/en-US/project-structure.md +49 -0
  41. data/docs/en-US/ruby-first-roadmap.md +186 -0
  42. data/docs/en-US/scaffolds.md +57 -0
  43. data/docs/en-US/semantic-refactoring.md +198 -0
  44. data/docs/en-US/team-server.md +32 -0
  45. data/docs/en-US/validation-matrix.md +257 -0
  46. data/docs/en-US/vetclinic-acceptance.md +23 -0
  47. data/docs/en-US/writing.md +655 -0
  48. data/docs/pt-BR/README.md +24 -0
  49. data/docs/pt-BR/architectural-patterns.md +25 -0
  50. data/docs/pt-BR/architectural-standard.md +533 -0
  51. data/docs/pt-BR/architecture.md +300 -0
  52. data/docs/pt-BR/compiler.md +162 -0
  53. data/docs/pt-BR/conventions.md +19 -0
  54. data/docs/pt-BR/design-system.md +65 -0
  55. data/docs/pt-BR/entity-dsl.md +116 -0
  56. data/docs/pt-BR/native-runtime-quality-report.md +73 -0
  57. data/docs/pt-BR/oql-sql.md +208 -0
  58. data/docs/pt-BR/platform-api-integration.md +36 -0
  59. data/docs/pt-BR/platform-operations.md +260 -0
  60. data/docs/pt-BR/project-structure.md +32 -0
  61. data/docs/pt-BR/ruby-first-roadmap.md +239 -0
  62. data/docs/pt-BR/scaffolds.md +104 -0
  63. data/docs/pt-BR/semantic-refactoring.md +55 -0
  64. data/docs/pt-BR/team-server.md +59 -0
  65. data/docs/pt-BR/validation-matrix.md +86 -0
  66. data/docs/pt-BR/vetclinic-acceptance.md +50 -0
  67. data/docs/pt-BR/writing.md +177 -0
  68. data/examples/sudoku_evaluation.rb +14 -0
  69. data/examples/sudoku_functional_test.rb +28 -0
  70. data/lib/mxrb/architecture/graph.rb +124 -0
  71. data/lib/mxrb/architecture/validator.rb +98 -0
  72. data/lib/mxrb/benchmark.rb +33 -0
  73. data/lib/mxrb/compare.rb +390 -0
  74. data/lib/mxrb/compiler/adapter.rb +96 -0
  75. data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
  76. data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
  77. data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
  78. data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
  79. data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
  80. data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
  81. data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
  82. data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
  83. data/lib/mxrb/compiler/constants_materializer.rb +111 -0
  84. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
  85. data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
  86. data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
  87. data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
  88. data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
  89. data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
  90. data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
  91. data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
  92. data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
  93. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
  94. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
  95. data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
  96. data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
  97. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
  98. data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
  99. data/lib/mxrb/compiler/mda.rb +99 -0
  100. data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
  101. data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
  102. data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
  103. data/lib/mxrb/compiler/model_package.rb +116 -0
  104. data/lib/mxrb/compiler/model_values.rb +67 -0
  105. data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
  106. data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
  107. data/lib/mxrb/compiler/packager.rb +100 -0
  108. data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
  109. data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
  110. data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
  111. data/lib/mxrb/compiler/portable_packager.rb +356 -0
  112. data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
  113. data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
  114. data/lib/mxrb/compiler/project_materializer.rb +139 -0
  115. data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
  116. data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
  117. data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
  118. data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
  119. data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
  120. data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
  121. data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
  122. data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
  123. data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
  124. data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
  125. data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
  126. data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
  127. data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
  128. data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
  129. data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
  130. data/lib/mxrb/compiler/security_materializer.rb +104 -0
  131. data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
  132. data/lib/mxrb/compiler/settings_materializer.rb +25 -0
  133. data/lib/mxrb/compiler/source_model.rb +128 -0
  134. data/lib/mxrb/compiler/system_model_seed.rb +88 -0
  135. data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
  136. data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
  137. data/lib/mxrb/compiler/translation_materializer.rb +95 -0
  138. data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
  139. data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
  140. data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
  141. data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
  142. data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
  143. data/lib/mxrb/doctor.rb +116 -0
  144. data/lib/mxrb/dsl/builder.rb +1636 -0
  145. data/lib/mxrb/errors.rb +18 -0
  146. data/lib/mxrb/evaluation.rb +131 -0
  147. data/lib/mxrb/exporter.rb +1716 -0
  148. data/lib/mxrb/frontend/migrator.rb +775 -0
  149. data/lib/mxrb/functional.rb +307 -0
  150. data/lib/mxrb/github/annotator.rb +161 -0
  151. data/lib/mxrb/initializer.rb +257 -0
  152. data/lib/mxrb/integrity/validator.rb +136 -0
  153. data/lib/mxrb/io/bson_codec.rb +145 -0
  154. data/lib/mxrb/io/mpr_file.rb +558 -0
  155. data/lib/mxrb/io/mxunit_codec.rb +42 -0
  156. data/lib/mxrb/marketplace.rb +380 -0
  157. data/lib/mxrb/model/association.rb +98 -0
  158. data/lib/mxrb/model/attribute.rb +100 -0
  159. data/lib/mxrb/model/connector.rb +17 -0
  160. data/lib/mxrb/model/design_materializer.rb +133 -0
  161. data/lib/mxrb/model/design_migration.rb +126 -0
  162. data/lib/mxrb/model/design_system.rb +111 -0
  163. data/lib/mxrb/model/domain_model.rb +51 -0
  164. data/lib/mxrb/model/entity.rb +180 -0
  165. data/lib/mxrb/model/menu.rb +38 -0
  166. data/lib/mxrb/model/microflow.rb +100 -0
  167. data/lib/mxrb/model/module.rb +142 -0
  168. data/lib/mxrb/model/navigation.rb +127 -0
  169. data/lib/mxrb/model/page.rb +410 -0
  170. data/lib/mxrb/model/project.rb +187 -0
  171. data/lib/mxrb/model/unit.rb +48 -0
  172. data/lib/mxrb/module_initializer.rb +75 -0
  173. data/lib/mxrb/official_marketplace/content_api.rb +292 -0
  174. data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
  175. data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
  176. data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
  177. data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
  178. data/lib/mxrb/official_marketplace.rb +720 -0
  179. data/lib/mxrb/oql/analyzer.rb +155 -0
  180. data/lib/mxrb/oql/index_advisor.rb +85 -0
  181. data/lib/mxrb/oql/plan_analyzer.rb +185 -0
  182. data/lib/mxrb/oql/server.rb +132 -0
  183. data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
  184. data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
  185. data/lib/mxrb/oql/workload_analyzer.rb +175 -0
  186. data/lib/mxrb/oql/workload_baseline.rb +60 -0
  187. data/lib/mxrb/oql.rb +424 -0
  188. data/lib/mxrb/project_lifecycle.rb +76 -0
  189. data/lib/mxrb/protocols/plan.rb +86 -0
  190. data/lib/mxrb/protocols.rb +123 -0
  191. data/lib/mxrb/runtime/database_workspace.rb +677 -0
  192. data/lib/mxrb/runtime/docker_executor.rb +41 -0
  193. data/lib/mxrb/runtime/docker_workspace.rb +29 -0
  194. data/lib/mxrb/runtime/executor.rb +169 -0
  195. data/lib/mxrb/runtime/java_locator.rb +26 -0
  196. data/lib/mxrb/runtime/native.rb +474 -0
  197. data/lib/mxrb/runtime/sql_server_database.rb +146 -0
  198. data/lib/mxrb/runtime/toolchain.rb +82 -0
  199. data/lib/mxrb/scaffold/cli.rb +113 -0
  200. data/lib/mxrb/scaffold/generator.rb +246 -0
  201. data/lib/mxrb/scaffold/help.rb +80 -0
  202. data/lib/mxrb/scaffold/page_templates.rb +54 -0
  203. data/lib/mxrb/scaffold/recipes.rb +198 -0
  204. data/lib/mxrb/scaffold/registry.rb +82 -0
  205. data/lib/mxrb/scaffold/templates.rb +723 -0
  206. data/lib/mxrb/scaffold/transaction.rb +84 -0
  207. data/lib/mxrb/schema/tables.rb +55 -0
  208. data/lib/mxrb/semantic/analyzer.rb +473 -0
  209. data/lib/mxrb/semantic/batch_plan.rb +68 -0
  210. data/lib/mxrb/semantic/domain_mutator.rb +419 -0
  211. data/lib/mxrb/semantic/embedder.rb +51 -0
  212. data/lib/mxrb/semantic/extractor.rb +290 -0
  213. data/lib/mxrb/semantic/index.rb +623 -0
  214. data/lib/mxrb/semantic/inliner.rb +216 -0
  215. data/lib/mxrb/semantic/mover.rb +153 -0
  216. data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
  217. data/lib/mxrb/semantic/remover.rb +70 -0
  218. data/lib/mxrb/semantic/renamer.rb +211 -0
  219. data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
  220. data/lib/mxrb/semantic/vec_store.rb +75 -0
  221. data/lib/mxrb/team_server.rb +390 -0
  222. data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
  223. data/lib/mxrb/templates/project/11.12.1.json +32 -0
  224. data/lib/mxrb/templates/project/6.10.8.json +50 -0
  225. data/lib/mxrb/templates/project/7.17.0.json +50 -0
  226. data/lib/mxrb/templates/project/7.5.0.json +50 -0
  227. data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
  228. data/lib/mxrb/version.rb +5 -0
  229. data/lib/mxrb/widget_package.rb +338 -0
  230. data/lib/mxrb/widget_synchronizer.rb +31 -0
  231. data/lib/mxrb/writer.rb +3775 -0
  232. data/lib/mxrb.rb +188 -0
  233. data/marketplace/catalog.json +10 -0
  234. data/marketplace/modules/shared-kernel/domain/README.md +4 -0
  235. data/marketplace/modules/shared-kernel/module.rb +5 -0
  236. data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
  237. metadata +447 -0
@@ -0,0 +1,558 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "securerandom"
5
+ require "json"
6
+ require_relative "bson_codec"
7
+ require_relative "mxunit_codec"
8
+
9
+ module Mxrb
10
+ module IO
11
+ # Low-level SQLite wrapper for .mpr files.
12
+ #
13
+ # Schema reality (from reverse engineering):
14
+ # Unit(UnitID BLOB, ContainerID BLOB, ContainmentName TEXT,
15
+ # TreeConflict LONG, ContentsHash TEXT, ContentsConflict TEXT, Contents BLOB)
16
+ # _MetaData(_ProductVersion TEXT, _BuildVersion TEXT, _SchemaHash TEXT)
17
+ # (older MPRs use MendixVersion instead of _ProductVersion)
18
+ #
19
+ # UnitID and ContainerID are 16-byte MS-GUID blobs, not integers.
20
+ # Contents is a BSON blob (v1) or NULL (v2, where .mxunit files hold the data).
21
+ # The $Type of each unit is embedded in its BSON Contents, not in a separate table.
22
+ class MprFile
23
+ attr_reader :path, :format_version
24
+
25
+ def write_stats
26
+ @write_stats ||= { inserted: 0, updated: 0, skipped: 0, deleted: 0 }
27
+ end
28
+
29
+ def readonly? = @readonly
30
+
31
+ def initialize(path, readonly: false)
32
+ @path = File.expand_path(path)
33
+ @readonly = readonly
34
+ @db = open_db
35
+ @write_stats = { inserted: 0, updated: 0, skipped: 0, deleted: 0 }
36
+ validate!
37
+ @format_version = detect_format
38
+ end
39
+
40
+ def self.open(path, readonly: false)
41
+ new(path, readonly: readonly)
42
+ end
43
+
44
+ # ── Metadata ─────────────────────────────────────────────────────────
45
+
46
+ def mendix_version
47
+ @mendix_version ||= begin
48
+ row = @db.get_first_row("SELECT _ProductVersion FROM _MetaData LIMIT 1") rescue nil
49
+ row ||= @db.get_first_row("SELECT MendixVersion FROM _MetaData LIMIT 1") rescue nil
50
+ row&.first
51
+ end
52
+ end
53
+
54
+ def update_version!(version)
55
+ version_str = version.to_s
56
+ # Try new-style column first, fall back to old-style
57
+ begin
58
+ @db.execute("UPDATE _MetaData SET _ProductVersion = ?, _BuildVersion = ?",
59
+ [version_str, version_str])
60
+ rescue SQLite3::Exception
61
+ @db.execute("UPDATE _MetaData SET MendixVersion = ?", [version_str])
62
+ end
63
+ @mendix_version = version_str
64
+ end
65
+
66
+ def project_name
67
+ @project_name ||= begin
68
+ # Name lives in the root Unit's BSON ($QualifiedName or Name field)
69
+ root = root_unit
70
+ return nil unless root
71
+
72
+ doc = parse_contents(root)
73
+ doc["Name"] || doc["name"] || File.basename(@path, ".mpr")
74
+ end
75
+ end
76
+
77
+ # ── Unit access ───────────────────────────────────────────────────────
78
+
79
+ # The root Unit is the one where UnitID == ContainerID.
80
+ def root_unit
81
+ @root_unit ||= begin
82
+ row = @db.get_first_row(
83
+ "SELECT #{unit_select_columns} FROM Unit " \
84
+ "WHERE UnitID = ContainerID LIMIT 1"
85
+ )
86
+ row ? raw_to_hash(row) : nil
87
+ end
88
+ end
89
+
90
+ # All units with a given ContainmentName.
91
+ def units_by_containment(name)
92
+ @db.execute(
93
+ "SELECT #{unit_select_columns} FROM Unit " \
94
+ "WHERE ContainmentName = ?",
95
+ [name]
96
+ ).map { raw_to_hash(_1) }
97
+ end
98
+
99
+ # Units directly contained by a given parent UUID.
100
+ def children_of(parent_uuid)
101
+ blob = BsonCodec.uuid_to_blob(parent_uuid)
102
+ @db.execute(
103
+ "SELECT #{unit_select_columns} FROM Unit " \
104
+ "WHERE ContainerID = ? AND UnitID != ContainerID",
105
+ [blob]
106
+ ).map { raw_to_hash(_1) }
107
+ end
108
+
109
+ # Single unit by UUID string.
110
+ def unit(uuid)
111
+ blob = BsonCodec.uuid_to_blob(uuid)
112
+ row = @db.get_first_row(
113
+ "SELECT #{unit_select_columns} FROM Unit WHERE UnitID = ?",
114
+ [blob]
115
+ )
116
+ row ? raw_to_hash(row) : nil
117
+ end
118
+
119
+ # All units (for exploration / reverse engineering).
120
+ def all_units
121
+ @db.execute(
122
+ "SELECT #{unit_select_columns} FROM Unit"
123
+ ).map { raw_to_hash(_1) }
124
+ end
125
+
126
+ # Parse BSON from a raw unit hash.
127
+ def parse_contents(raw_unit)
128
+ blob = raw_unit["Contents"]
129
+ if (blob.nil? || blob.empty?) && @format_version == :v2
130
+ unit_path = MxunitCodec.path_for(contents_dir, raw_unit.fetch("UnitID"))
131
+ return {} unless File.file?(unit_path)
132
+ return MxunitCodec.read(unit_path)
133
+ end
134
+ return {} if blob.nil? || blob.empty?
135
+
136
+ BsonCodec.parse(blob)
137
+ end
138
+
139
+ def content_bytes(raw_unit)
140
+ blob = raw_unit["Contents"]
141
+ if (blob.nil? || blob.empty?) && @format_version == :v2
142
+ unit_path = MxunitCodec.path_for(contents_dir, raw_unit.fetch("UnitID"))
143
+ return nil unless File.file?(unit_path)
144
+
145
+ return File.binread(unit_path)
146
+ end
147
+ blob
148
+ end
149
+
150
+ def content_path(raw_unit)
151
+ return nil unless @format_version == :v2
152
+
153
+ MxunitCodec.path_for(contents_dir, raw_unit.fetch("UnitID"))
154
+ end
155
+
156
+ def content_files
157
+ return [] unless @format_version == :v2 && File.directory?(contents_dir)
158
+
159
+ Dir.glob(File.join(contents_dir, "**", "*.mxunit")).sort
160
+ end
161
+
162
+ # ── Writes ────────────────────────────────────────────────────────────
163
+
164
+ # Insert a new unit. Returns the assigned UUID.
165
+ def insert_unit(container_uuid:, containment_name:, contents_doc:)
166
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
167
+
168
+ uuid = BsonCodec.extract_id(contents_doc["$ID"] || contents_doc["\$ID"]) || SecureRandom.uuid
169
+ unless contents_doc.key?("$ID") || contents_doc.key?("\$ID")
170
+ contents_doc = { "$ID" => uuid }.merge(contents_doc)
171
+ end
172
+ unit_blob = BsonCodec.uuid_to_blob(uuid)
173
+ parent_blob = BsonCodec.uuid_to_blob(container_uuid)
174
+ bson_bytes = BsonCodec.serialize(contents_doc)
175
+ hash = BsonCodec.contents_hash(bson_bytes)
176
+ stored_bytes = @format_version == :v2 ? nil : bson_bytes
177
+ columns = %w[UnitID ContainerID ContainmentName TreeConflict ContentsHash]
178
+ values = [unit_blob, parent_blob, containment_name, 0, hash]
179
+ if (conflicts = conflicts_column)
180
+ columns << conflicts
181
+ values << ""
182
+ end
183
+ if contents_column?
184
+ columns << "Contents"
185
+ values << stored_bytes
186
+ end
187
+ placeholders = (["?"] * columns.length).join(", ")
188
+ @db.execute(
189
+ "INSERT INTO Unit (#{columns.join(', ')}) VALUES (#{placeholders})",
190
+ values
191
+ )
192
+ write_v2_unit(uuid, bson_bytes) if @format_version == :v2
193
+ write_stats[:inserted] += 1
194
+ uuid
195
+ end
196
+
197
+ # Update an existing unit's contents. Recalculates ContentsHash automatically.
198
+ def update_unit(uuid, contents_doc)
199
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
200
+
201
+ blob = BsonCodec.uuid_to_blob(uuid)
202
+ bson_bytes = BsonCodec.serialize(contents_doc)
203
+ hash = BsonCodec.contents_hash(bson_bytes)
204
+ current = unit(uuid)
205
+ if current && current['ContentsHash'] == hash
206
+ write_stats[:skipped] += 1
207
+ return false
208
+ end
209
+
210
+ if contents_column?
211
+ @db.execute(
212
+ "UPDATE Unit SET Contents = ?, ContentsHash = ? WHERE UnitID = ?",
213
+ [@format_version == :v2 ? nil : bson_bytes, hash, blob]
214
+ )
215
+ else
216
+ @db.execute(
217
+ "UPDATE Unit SET ContentsHash = ? WHERE UnitID = ?",
218
+ [hash, blob]
219
+ )
220
+ end
221
+ write_v2_unit(uuid, bson_bytes) if @format_version == :v2
222
+ write_stats[:updated] += 1
223
+ true
224
+ end
225
+
226
+ def delete_unit(uuid)
227
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
228
+ @db.execute("DELETE FROM Unit WHERE UnitID = ?", [BsonCodec.uuid_to_blob(uuid)])
229
+ removed = FileUtils.rm_f(MxunitCodec.path_for(contents_dir, uuid)) if @format_version == :v2
230
+ write_stats[:deleted] += 1
231
+ removed
232
+ end
233
+
234
+ def relocate_unit(uuid, container_uuid:, containment_name:)
235
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
236
+
237
+ @db.execute(
238
+ "UPDATE Unit SET ContainerID = ?, ContainmentName = ? WHERE UnitID = ?",
239
+ [
240
+ BsonCodec.uuid_to_blob(container_uuid),
241
+ containment_name.to_s,
242
+ BsonCodec.uuid_to_blob(uuid)
243
+ ]
244
+ )
245
+ end
246
+
247
+ def transaction(&)
248
+ @db.transaction(&)
249
+ end
250
+
251
+ # Creates a consistent point-in-time backup using SQLite's VACUUM INTO.
252
+ # Falls back to a WAL checkpoint + file copy on older SQLite versions.
253
+ def backup!(dest_path)
254
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
255
+
256
+ FileUtils.rm_f(dest_path)
257
+ @db.execute("VACUUM INTO ?", [dest_path])
258
+ rescue SQLite3::Exception
259
+ @db.execute("PRAGMA wal_checkpoint(FULL)") rescue nil
260
+ FileUtils.cp(@path, dest_path)
261
+ end
262
+
263
+ # Restores the database from a backup file, replacing the current contents.
264
+ # Closes and reopens the underlying SQLite connection.
265
+ def restore_from!(backup_path)
266
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
267
+
268
+ @db.close
269
+ FileUtils.cp(backup_path, @path)
270
+ @db = open_db
271
+ @mendix_version = nil
272
+ @format_version = detect_format
273
+ end
274
+
275
+ # ── Exploration helpers ───────────────────────────────────────────────
276
+
277
+ def tables
278
+ @db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").flatten
279
+ end
280
+
281
+ def table_info(name)
282
+ @db.table_info(name)
283
+ end
284
+
285
+ def query(sql, *binds)
286
+ @db.execute(sql, *binds)
287
+ end
288
+
289
+ # ── Semantic index cache ──────────────────────────────────────────────────
290
+
291
+ # Returns the cached index JSON if the fingerprint matches, nil otherwise.
292
+ def read_index_cache(fingerprint)
293
+ return nil unless tables.include?("_MxrbIndexCache")
294
+
295
+ @db.get_first_value(
296
+ "SELECT IndexData FROM _MxrbIndexCache WHERE Fingerprint = ?", [fingerprint]
297
+ )
298
+ rescue SQLite3::Exception
299
+ nil
300
+ end
301
+
302
+ # Persists the index JSON keyed by fingerprint. No-op when read-only or on error.
303
+ def write_index_cache(fingerprint, json)
304
+ return if @readonly || json.nil?
305
+
306
+ @db.execute(<<~SQL)
307
+ CREATE TABLE IF NOT EXISTS _MxrbIndexCache (
308
+ Fingerprint TEXT PRIMARY KEY,
309
+ IndexData TEXT NOT NULL
310
+ )
311
+ SQL
312
+ @db.execute(
313
+ "INSERT INTO _MxrbIndexCache (Fingerprint, IndexData) VALUES (?, ?) " \
314
+ "ON CONFLICT(Fingerprint) DO UPDATE SET IndexData = excluded.IndexData",
315
+ [fingerprint, json]
316
+ )
317
+ @db.execute(
318
+ "DELETE FROM _MxrbIndexCache WHERE Fingerprint <> ?", [fingerprint]
319
+ )
320
+ rescue SQLite3::Exception
321
+ nil
322
+ end
323
+
324
+ # Returns cache size and fingerprints without parsing the cached payload.
325
+ def index_cache_info(current_fingerprint: nil)
326
+ unless tables.include?("_MxrbIndexCache")
327
+ return {
328
+ present: false, entries: 0, bytes: 0,
329
+ fingerprints: [].freeze, current_fingerprint:,
330
+ hit: false
331
+ }.freeze
332
+ end
333
+
334
+ rows = @db.execute(
335
+ "SELECT Fingerprint, LENGTH(IndexData) FROM _MxrbIndexCache ORDER BY Fingerprint"
336
+ )
337
+ fingerprints = rows.map { _1[0].to_s }.freeze
338
+ {
339
+ present: !rows.empty?,
340
+ entries: rows.size,
341
+ bytes: rows.sum { _1[1].to_i },
342
+ fingerprints:,
343
+ current_fingerprint:,
344
+ hit: current_fingerprint && fingerprints.include?(current_fingerprint)
345
+ }.freeze
346
+ rescue SQLite3::Exception
347
+ {
348
+ present: false, entries: 0, bytes: 0,
349
+ fingerprints: [].freeze, current_fingerprint:,
350
+ hit: false
351
+ }.freeze
352
+ end
353
+
354
+ # Clears cached semantic data while preserving the cache table.
355
+ def clear_index_cache!
356
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
357
+ return 0 unless tables.include?("_MxrbIndexCache")
358
+
359
+ count = @db.get_first_value("SELECT COUNT(*) FROM _MxrbIndexCache").to_i
360
+ @db.execute("DELETE FROM _MxrbIndexCache")
361
+ count
362
+ end
363
+
364
+ # ── Vector index (sqlite-vec) ─────────────────────────────────────────────
365
+
366
+ # Loads the optional sqlite-vec extension into the active connection.
367
+ def load_vec_extension!
368
+ return true if @vec_loaded
369
+
370
+ require "sqlite_vec"
371
+ @db.enable_load_extension(true)
372
+ begin
373
+ SqliteVec.load(@db)
374
+ ensure
375
+ @db.enable_load_extension(false)
376
+ end
377
+ @vec_loaded = true
378
+ end
379
+
380
+ def ensure_vec_table!(table, dimension)
381
+ ensure_vector_write!
382
+ identifier = vector_identifier(table)
383
+ size = Integer(dimension)
384
+ raise ArgumentError, "vector dimension must be positive" unless size.positive?
385
+
386
+ @db.execute(<<~SQL)
387
+ CREATE VIRTUAL TABLE IF NOT EXISTS #{identifier}
388
+ USING vec0(artifact_id TEXT PRIMARY KEY, embedding FLOAT[#{size}])
389
+ SQL
390
+ end
391
+
392
+ def ensure_vec_meta_table!(table)
393
+ ensure_vector_write!
394
+ identifier = vector_identifier(table)
395
+ @db.execute(<<~SQL)
396
+ CREATE TABLE IF NOT EXISTS #{identifier} (
397
+ ID INTEGER PRIMARY KEY CHECK (ID = 1),
398
+ Backend TEXT NOT NULL,
399
+ Dimension INTEGER NOT NULL,
400
+ Fingerprint TEXT NOT NULL
401
+ )
402
+ SQL
403
+ end
404
+
405
+ def write_vec_meta!(table, backend, dimension, fingerprint)
406
+ ensure_vector_write!
407
+ identifier = vector_identifier(table)
408
+ @db.execute(
409
+ "INSERT OR REPLACE INTO #{identifier} " \
410
+ "(ID, Backend, Dimension, Fingerprint) VALUES (1, ?, ?, ?)",
411
+ [backend, dimension, fingerprint]
412
+ )
413
+ end
414
+
415
+ def vec_meta(table)
416
+ name = table.to_s
417
+ return nil unless tables.include?(name)
418
+
419
+ identifier = vector_identifier(name)
420
+ row = @db.get_first_row(
421
+ "SELECT Backend, Dimension, Fingerprint FROM #{identifier} WHERE ID = 1"
422
+ )
423
+ return nil unless row
424
+
425
+ { backend: row[0], dimension: row[1], fingerprint: row[2] }
426
+ rescue SQLite3::Exception
427
+ nil
428
+ end
429
+
430
+ def vec_upsert(table, artifact_id, json_vec)
431
+ ensure_vector_write!
432
+ identifier = vector_identifier(table)
433
+ @db.execute(
434
+ "INSERT INTO #{identifier}(artifact_id, embedding) VALUES (?, ?)",
435
+ [artifact_id, json_vec]
436
+ )
437
+ end
438
+
439
+ def vec_knn(table, json_vec, limit)
440
+ identifier = vector_identifier(table)
441
+ @db.execute(
442
+ "SELECT artifact_id, distance FROM #{identifier} " \
443
+ "WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
444
+ [json_vec, Integer(limit)]
445
+ ).map { { id: _1[0], distance: _1[1] } }
446
+ end
447
+
448
+ def vec_transaction(&)
449
+ ensure_vector_write!
450
+ @db.transaction(&)
451
+ end
452
+
453
+ def vec_drop_index!(vec_table, meta_table)
454
+ ensure_vector_write!
455
+ @db.execute("DROP TABLE IF EXISTS #{vector_identifier(vec_table)}")
456
+ @db.execute("DROP TABLE IF EXISTS #{vector_identifier(meta_table)}")
457
+ end
458
+
459
+ # ── Architecture metadata ─────────────────────────────────────────────────
460
+
461
+ # mxrb-only architecture metadata for concepts without a native Mendix
462
+ # unit (ports/repositories) or bindings awaiting a concrete widget tree.
463
+ def architecture_definition
464
+ return nil unless tables.include?("_MxrbArchitecture")
465
+ json = @db.get_first_value("SELECT Definition FROM _MxrbArchitecture WHERE ID = 1")
466
+ json && JSON.parse(json, symbolize_names: true)
467
+ end
468
+
469
+ def write_architecture_definition(definition)
470
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
471
+ @db.execute(<<~SQL)
472
+ CREATE TABLE IF NOT EXISTS _MxrbArchitecture (
473
+ ID INTEGER PRIMARY KEY CHECK (ID = 1),
474
+ Version INTEGER NOT NULL,
475
+ Definition TEXT NOT NULL
476
+ )
477
+ SQL
478
+ @db.execute(
479
+ "INSERT OR REPLACE INTO _MxrbArchitecture (ID, Version, Definition) VALUES (1, 1, ?)",
480
+ [JSON.generate(definition)]
481
+ )
482
+ end
483
+
484
+ def close
485
+ @db.close
486
+ end
487
+
488
+ private
489
+
490
+ def ensure_vector_write!
491
+ raise ReadOnlyError, "Opened in read-only mode" if @readonly
492
+ end
493
+
494
+ def vector_identifier(value)
495
+ name = value.to_s
496
+ raise ArgumentError, "invalid vector table name: #{value.inspect}" \
497
+ unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
498
+
499
+ name
500
+ end
501
+
502
+ def open_db
503
+ mode = @readonly ? SQLite3::Constants::Open::READONLY : SQLite3::Constants::Open::READWRITE
504
+ SQLite3::Database.new(@path, { flags: mode })
505
+ rescue SQLite3::CantOpenException => e
506
+ raise NotMprError, "Cannot open #{@path}: #{e.message}"
507
+ end
508
+
509
+ def validate!
510
+ magic = File.binread(@path, 16) rescue nil
511
+ raise NotMprError, "#{@path}: not a valid SQLite file" unless magic&.start_with?("SQLite format 3")
512
+ raise NotMprError, "#{@path}: Unit table missing — not a valid .mpr file" unless tables.include?("Unit")
513
+ end
514
+
515
+ # MPR v2 stores unit contents in mprcontents/ folder next to the .mpr.
516
+ def detect_format
517
+ File.directory?(contents_dir) ? :v2 : :v1
518
+ end
519
+
520
+ def contents_dir
521
+ File.join(File.dirname(@path), "mprcontents")
522
+ end
523
+
524
+ def unit_columns
525
+ @unit_columns ||= table_info("Unit").map { _1["name"] || _1[:name] }
526
+ end
527
+
528
+ def contents_column?
529
+ unit_columns.include?("Contents")
530
+ end
531
+
532
+ def conflicts_column
533
+ return "ContentsConflicts" if unit_columns.include?("ContentsConflicts")
534
+ return "ContentsConflict" if unit_columns.include?("ContentsConflict")
535
+
536
+ nil
537
+ end
538
+
539
+ def unit_select_columns
540
+ contents = contents_column? ? "Contents" : "NULL AS Contents"
541
+ "UnitID, ContainerID, ContainmentName, ContentsHash, #{contents}"
542
+ end
543
+
544
+ def write_v2_unit(uuid, bytes)
545
+ MxunitCodec.write_atomic(MxunitCodec.path_for(contents_dir, uuid), bytes)
546
+ end
547
+
548
+ def raw_to_hash(row)
549
+ keys = %w[UnitID ContainerID ContainmentName ContentsHash Contents]
550
+ h = keys.zip(row).to_h
551
+ # Convert blob UUIDs to strings for ergonomics
552
+ h["UnitID"] = BsonCodec.blob_to_uuid(h["UnitID"]) if h["UnitID"]
553
+ h["ContainerID"] = BsonCodec.blob_to_uuid(h["ContainerID"]) if h["ContainerID"]
554
+ h
555
+ end
556
+ end
557
+ end
558
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "bson_codec"
5
+
6
+ module Mxrb
7
+ module IO
8
+ # Storage adapter for MPR v2 unit files.
9
+ #
10
+ # Mendix currently stores the serialized unit payload outside SQLite. The
11
+ # payload is kept opaque here: known BSON payloads can be decoded, while
12
+ # unknown/newer encodings fail explicitly instead of being overwritten.
13
+ module MxunitCodec
14
+ class UnsupportedFormat < SerializationError; end
15
+
16
+ def self.path_for(contents_dir, uuid)
17
+ hex = uuid.delete("-").downcase
18
+ File.join(contents_dir, hex[0, 2], hex[2, 2], "#{uuid.downcase}.mxunit")
19
+ end
20
+
21
+ def self.read(path)
22
+ bytes = File.binread(path)
23
+ BsonCodec.parse(bytes)
24
+ rescue SerializationError => e
25
+ raise UnsupportedFormat, "#{path}: unsupported .mxunit encoding (#{e.message})"
26
+ end
27
+
28
+ def self.serialize(doc)
29
+ BsonCodec.serialize(doc)
30
+ end
31
+
32
+ def self.write_atomic(path, bytes)
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ temporary = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
35
+ File.binwrite(temporary, bytes)
36
+ File.rename(temporary, path)
37
+ ensure
38
+ FileUtils.rm_f(temporary) if temporary
39
+ end
40
+ end
41
+ end
42
+ end