synthra 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 (302) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +4 -0
  3. data/.rubocop.yml +40 -0
  4. data/.yardopts +18 -0
  5. data/ACCESS_DOCS.md +116 -0
  6. data/ADVANCED_FEATURES_SUMMARY.md +245 -0
  7. data/CHANGELOG.md +498 -0
  8. data/CODE_OF_CONDUCT.md +122 -0
  9. data/CONTRIBUTING.md +307 -0
  10. data/DOCUMENTATION.md +204 -0
  11. data/DOCUMENTATION_GUIDE.md +194 -0
  12. data/LICENSE +22 -0
  13. data/README.md +1458 -0
  14. data/Rakefile +128 -0
  15. data/benchmark/README.md +591 -0
  16. data/benchmark/batch_generation.rb +63 -0
  17. data/benchmark/engine_comparison.rb +295 -0
  18. data/benchmark/single_record.rb +108 -0
  19. data/benchmark/streaming.rb +74 -0
  20. data/docs/ACTIVERECORD_INFERENCE.md +524 -0
  21. data/docs/API_SERVER.md +278 -0
  22. data/docs/CONFIG_FILE.md +315 -0
  23. data/docs/DATA_CONTRACTS.md +312 -0
  24. data/docs/ENHANCED_REPL.md +304 -0
  25. data/docs/FACTORY_BOT.md +271 -0
  26. data/docs/GITHUB_ACTION.md +420 -0
  27. data/docs/GRAPHQL_EXPORT.md +303 -0
  28. data/docs/GRAPHQL_FEDERATION.md +651 -0
  29. data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
  30. data/docs/LSP.md +189 -0
  31. data/docs/MIGRATION_GENERATOR.md +547 -0
  32. data/docs/MOCK_SERVER.md +280 -0
  33. data/docs/NATIVE_ENGINE.md +298 -0
  34. data/docs/OPENAPI_EXPORT.md +252 -0
  35. data/docs/PERFORMANCE_MODE.md +228 -0
  36. data/docs/PERSONAS.md +547 -0
  37. data/docs/PROPERTY_TESTING.md +284 -0
  38. data/docs/PROTOBUF_EXPORT.md +276 -0
  39. data/docs/QUALITY_METRICS.md +555 -0
  40. data/docs/QUICK_REFERENCE.md +88 -0
  41. data/docs/RAILS_ENGINE.md +404 -0
  42. data/docs/RAILS_INTEGRATION.md +454 -0
  43. data/docs/README.md +175 -0
  44. data/docs/REPL_QUICK_REFERENCE.md +149 -0
  45. data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
  46. data/docs/SCENARIOS.md +576 -0
  47. data/docs/SECURITY_TESTING.md +318 -0
  48. data/docs/SNAPSHOT_TESTING.md +308 -0
  49. data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
  50. data/docs/TERRAFORM_EXPORT.md +447 -0
  51. data/docs/TIME_TRAVEL.md +507 -0
  52. data/docs/USAGE_GUIDE.md +980 -0
  53. data/docs/WEBHOOK_SIMULATOR.md +579 -0
  54. data/docs/advanced_features.md +183 -0
  55. data/docs/api_reference.md +153 -0
  56. data/docs/behaviors.md +138 -0
  57. data/docs/best_practices.md +191 -0
  58. data/docs/dsl_reference.md +172 -0
  59. data/docs/examples.md +162 -0
  60. data/docs/generation_modes.md +125 -0
  61. data/docs/getting_started.md +92 -0
  62. data/docs/index.html +177 -0
  63. data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
  64. data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
  65. data/docs/troubleshooting.md +215 -0
  66. data/docs/type_reference.md +224 -0
  67. data/examples/hostile_mode_demo.rb +64 -0
  68. data/exe/synthra +6 -0
  69. data/ext/synthra_native/Cargo.lock +972 -0
  70. data/ext/synthra_native/Cargo.toml +46 -0
  71. data/ext/synthra_native/extconf.rb +41 -0
  72. data/ext/synthra_native/src/generator.rs +397 -0
  73. data/ext/synthra_native/src/lib.rs +229 -0
  74. data/ext/synthra_native/src/types.rs +1485 -0
  75. data/lib/generators/synthra/install_generator.rb +86 -0
  76. data/lib/generators/synthra/templates/api_response.dsl +15 -0
  77. data/lib/generators/synthra/templates/fake_data.rake +116 -0
  78. data/lib/generators/synthra/templates/synthra.yml +31 -0
  79. data/lib/generators/synthra/templates/synthra_support.rb +19 -0
  80. data/lib/generators/synthra/templates/user.dsl +11 -0
  81. data/lib/synthra/activerecord_inference.rb +394 -0
  82. data/lib/synthra/api.rb +381 -0
  83. data/lib/synthra/api_server.rb +718 -0
  84. data/lib/synthra/behaviors/applicator.rb +165 -0
  85. data/lib/synthra/behaviors/base.rb +155 -0
  86. data/lib/synthra/behaviors/close_connection.rb +60 -0
  87. data/lib/synthra/behaviors/deprecated.rb +100 -0
  88. data/lib/synthra/behaviors/failure.rb +69 -0
  89. data/lib/synthra/behaviors/latency.rb +103 -0
  90. data/lib/synthra/behaviors/partial_data.rb +83 -0
  91. data/lib/synthra/behaviors/randomize_order.rb +74 -0
  92. data/lib/synthra/behaviors/registry.rb +238 -0
  93. data/lib/synthra/behaviors/simulate_error.rb +79 -0
  94. data/lib/synthra/cli/commands/base.rb +105 -0
  95. data/lib/synthra/cli/commands/diff.rb +227 -0
  96. data/lib/synthra/cli/commands/docs.rb +67 -0
  97. data/lib/synthra/cli/commands/export.rb +150 -0
  98. data/lib/synthra/cli/commands/generate.rb +72 -0
  99. data/lib/synthra/cli/commands/import.rb +80 -0
  100. data/lib/synthra/cli/commands/lint.rb +53 -0
  101. data/lib/synthra/cli/commands/live.rb +57 -0
  102. data/lib/synthra/cli/commands/seed.rb +99 -0
  103. data/lib/synthra/cli/commands/validate.rb +28 -0
  104. data/lib/synthra/cli.rb +2471 -0
  105. data/lib/synthra/config_file.rb +129 -0
  106. data/lib/synthra/configuration.rb +281 -0
  107. data/lib/synthra/contracts_registry.rb +408 -0
  108. data/lib/synthra/database_seeder.rb +268 -0
  109. data/lib/synthra/deterministic_ids.rb +218 -0
  110. data/lib/synthra/documentation_generator.rb +414 -0
  111. data/lib/synthra/engine.rb +251 -0
  112. data/lib/synthra/errors.rb +1169 -0
  113. data/lib/synthra/export/base.rb +85 -0
  114. data/lib/synthra/export/csv.rb +101 -0
  115. data/lib/synthra/export/graphql.rb +266 -0
  116. data/lib/synthra/export/graphql_federation.rb +377 -0
  117. data/lib/synthra/export/graphviz.rb +258 -0
  118. data/lib/synthra/export/javascript.rb +327 -0
  119. data/lib/synthra/export/json_data.rb +61 -0
  120. data/lib/synthra/export/json_schema.rb +290 -0
  121. data/lib/synthra/export/openapi.rb +514 -0
  122. data/lib/synthra/export/protobuf.rb +483 -0
  123. data/lib/synthra/export/python.rb +560 -0
  124. data/lib/synthra/export/sql.rb +381 -0
  125. data/lib/synthra/export/sql_insert.rb +152 -0
  126. data/lib/synthra/export/terraform.rb +501 -0
  127. data/lib/synthra/export/type_mapping.rb +389 -0
  128. data/lib/synthra/export/typescript.rb +300 -0
  129. data/lib/synthra/export/xml_data.rb +104 -0
  130. data/lib/synthra/export/yaml_data.rb +56 -0
  131. data/lib/synthra/export.rb +404 -0
  132. data/lib/synthra/factory_bot_integration.rb +157 -0
  133. data/lib/synthra/field.rb +440 -0
  134. data/lib/synthra/functions/registry.rb +104 -0
  135. data/lib/synthra/generator/context.rb +372 -0
  136. data/lib/synthra/generator/engine.rb +336 -0
  137. data/lib/synthra/generator/faker_adapter.rb +425 -0
  138. data/lib/synthra/generator/modes.rb +444 -0
  139. data/lib/synthra/generator/resolver.rb +256 -0
  140. data/lib/synthra/generator/rng.rb +273 -0
  141. data/lib/synthra/generator/streamer.rb +63 -0
  142. data/lib/synthra/generator/uniqueness.rb +118 -0
  143. data/lib/synthra/initializer.rb +97 -0
  144. data/lib/synthra/limits.rb +279 -0
  145. data/lib/synthra/live_preview.rb +518 -0
  146. data/lib/synthra/loader_config.rb +78 -0
  147. data/lib/synthra/lsp/server.rb +689 -0
  148. data/lib/synthra/migration_generator.rb +301 -0
  149. data/lib/synthra/mixin.rb +174 -0
  150. data/lib/synthra/mock_server.rb +458 -0
  151. data/lib/synthra/native_engine.rb +304 -0
  152. data/lib/synthra/openapi_importer.rb +228 -0
  153. data/lib/synthra/output/json_formatter.rb +40 -0
  154. data/lib/synthra/output/ndjson_formatter.rb +55 -0
  155. data/lib/synthra/parser/ast.rb +1287 -0
  156. data/lib/synthra/parser/lexer.rb +1152 -0
  157. data/lib/synthra/parser/parser.rb +1664 -0
  158. data/lib/synthra/parser/tokens.rb +461 -0
  159. data/lib/synthra/performance_mode.rb +364 -0
  160. data/lib/synthra/personas.rb +397 -0
  161. data/lib/synthra/property_testing.rb +172 -0
  162. data/lib/synthra/quality_metrics.rb +405 -0
  163. data/lib/synthra/rails_test_helper.rb +248 -0
  164. data/lib/synthra/registry.rb +533 -0
  165. data/lib/synthra/relationships.rb +193 -0
  166. data/lib/synthra/repl/enhanced_repl.rb +607 -0
  167. data/lib/synthra/repl/formatter.rb +191 -0
  168. data/lib/synthra/scenarios.rb +423 -0
  169. data/lib/synthra/schema.rb +605 -0
  170. data/lib/synthra/schema_inheritance.rb +104 -0
  171. data/lib/synthra/schema_versioning.rb +212 -0
  172. data/lib/synthra/snapshot_testing.rb +199 -0
  173. data/lib/synthra/time_travel.rb +338 -0
  174. data/lib/synthra/type_definitions.rb +274 -0
  175. data/lib/synthra/types/address_location/addresses.rb +125 -0
  176. data/lib/synthra/types/address_location/airports.rb +200 -0
  177. data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
  178. data/lib/synthra/types/address_location/locations.rb +406 -0
  179. data/lib/synthra/types/base.rb +48 -0
  180. data/lib/synthra/types/commerce_products/commerce.rb +119 -0
  181. data/lib/synthra/types/commerce_products/companies.rb +71 -0
  182. data/lib/synthra/types/commerce_products/construction.rb +126 -0
  183. data/lib/synthra/types/commerce_products/products.rb +205 -0
  184. data/lib/synthra/types/core/collections.rb +513 -0
  185. data/lib/synthra/types/core/defaults.rb +76 -0
  186. data/lib/synthra/types/core/enums.rb +102 -0
  187. data/lib/synthra/types/core/identifiers.rb +445 -0
  188. data/lib/synthra/types/core/primitives.rb +586 -0
  189. data/lib/synthra/types/core/references.rb +466 -0
  190. data/lib/synthra/types/core/sequences.rb +304 -0
  191. data/lib/synthra/types/crypto/crypto.rb +162 -0
  192. data/lib/synthra/types/date_time/dates.rb +387 -0
  193. data/lib/synthra/types/finance_banking/banking.rb +424 -0
  194. data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
  195. data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
  196. data/lib/synthra/types/formula.rb +184 -0
  197. data/lib/synthra/types/health_medical/medical.rb +150 -0
  198. data/lib/synthra/types/hostile_payloads.rb +122 -0
  199. data/lib/synthra/types/json_array.rb +51 -0
  200. data/lib/synthra/types/media_entertainment/media.rb +62 -0
  201. data/lib/synthra/types/naughty_string.rb +30 -0
  202. data/lib/synthra/types/personal_names/chinese.rb +41 -0
  203. data/lib/synthra/types/personal_names/identifiers.rb +187 -0
  204. data/lib/synthra/types/personal_names/names.rb +246 -0
  205. data/lib/synthra/types/personal_names/national_id.rb +89 -0
  206. data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
  207. data/lib/synthra/types/regex.rb +248 -0
  208. data/lib/synthra/types/registry.rb +182 -0
  209. data/lib/synthra/types/repeating_element.rb +50 -0
  210. data/lib/synthra/types/scenario.rb +29 -0
  211. data/lib/synthra/types/technology_internet/apps.rb +67 -0
  212. data/lib/synthra/types/technology_internet/communication.rb +162 -0
  213. data/lib/synthra/types/technology_internet/devices.rb +80 -0
  214. data/lib/synthra/types/technology_internet/formats.rb +139 -0
  215. data/lib/synthra/types/technology_internet/networking.rb +143 -0
  216. data/lib/synthra/types/template.rb +92 -0
  217. data/lib/synthra/types/text_content/business.rb +128 -0
  218. data/lib/synthra/types/text_content/colors.rb +70 -0
  219. data/lib/synthra/types/text_content/misc.rb +237 -0
  220. data/lib/synthra/types/text_content/security.rb +130 -0
  221. data/lib/synthra/types/text_content/text_generation.rb +532 -0
  222. data/lib/synthra/types/travel/travel.rb +150 -0
  223. data/lib/synthra/utils/string_distance.rb +133 -0
  224. data/lib/synthra/validator/dsl_validator.rb +339 -0
  225. data/lib/synthra/validator/path_validator.rb +615 -0
  226. data/lib/synthra/version.rb +35 -0
  227. data/lib/synthra/webhook_simulator.rb +341 -0
  228. data/lib/synthra.rb +259 -0
  229. data/schemas/address.dsl +16 -0
  230. data/schemas/api_response.dsl +8 -0
  231. data/schemas/error_payload.dsl +9 -0
  232. data/schemas/order.dsl +10 -0
  233. data/schemas/order_item.dsl +8 -0
  234. data/schemas/payment.dsl +11 -0
  235. data/schemas/social_post.dsl +13 -0
  236. data/schemas/user.dsl +10 -0
  237. data/scripts/batch_fix_all.rb +96 -0
  238. data/scripts/delete_old_files.rb +32 -0
  239. data/scripts/fix_all_grouped_files.rb +137 -0
  240. data/scripts/fix_all_indentation.rb +48 -0
  241. data/scripts/fix_all_syntax.rb +124 -0
  242. data/scripts/fix_grouped_files.rb +184 -0
  243. data/scripts/fix_syntax_errors.rb +108 -0
  244. data/scripts/group_domain_types.rb +112 -0
  245. data/scripts/group_domain_types_fixed.rb +150 -0
  246. data/scripts/merge_domains_to_one_file.rb +68 -0
  247. data/scripts/move_existing_types.rb +130 -0
  248. data/scripts/split_grouped_types.rb +142 -0
  249. data/tech_docs/README.md +134 -0
  250. data/tech_docs/advanced/streaming.md +405 -0
  251. data/tech_docs/advanced/thread_safety.md +253 -0
  252. data/tech_docs/api/overview.md +485 -0
  253. data/tech_docs/appendices/type_chart.md +193 -0
  254. data/tech_docs/basic_concepts.md +383 -0
  255. data/tech_docs/behaviors/overview.md +346 -0
  256. data/tech_docs/dsl/complex_types.md +790 -0
  257. data/tech_docs/dsl/core_types.md +464 -0
  258. data/tech_docs/dsl/datetime_types.md +325 -0
  259. data/tech_docs/dsl/field_modifiers.md +414 -0
  260. data/tech_docs/dsl/grammar.md +431 -0
  261. data/tech_docs/dsl/schema_definition.md +399 -0
  262. data/tech_docs/export/README.md +276 -0
  263. data/tech_docs/installation.md +273 -0
  264. data/tech_docs/integration/ci_cd.md +707 -0
  265. data/tech_docs/integration/ci_cd_guide.md +579 -0
  266. data/tech_docs/integration/factory_bot.md +485 -0
  267. data/tech_docs/integration/rails.md +630 -0
  268. data/tech_docs/integration/rspec.md +449 -0
  269. data/tech_docs/modes/overview.md +350 -0
  270. data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
  271. data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
  272. data/tech_docs/quick_start.md +256 -0
  273. data/tech_docs/templates/README.md +805 -0
  274. data/tech_docs/tutorials/advanced.md +371 -0
  275. data/tech_docs/tutorials/getting_started.md +189 -0
  276. data/tech_docs/tutorials/intermediate.md +231 -0
  277. data/tech_docs/tutorials/template_gallery.md +569 -0
  278. data/tech_example/01_basic_usage.rb +238 -0
  279. data/tech_example/02_types_demo.rb +336 -0
  280. data/tech_example/04_cli_usage.md +429 -0
  281. data/tech_example/05_database_seeding.rb +283 -0
  282. data/tech_example/07_rspec_integration.rb +359 -0
  283. data/tech_example/10_custom_types.rb +387 -0
  284. data/tech_example/12_behaviors.rb +372 -0
  285. data/tech_example/13_twitter_dm_example.rb +120 -0
  286. data/tech_example/14_exact_json_structure.rb +253 -0
  287. data/tech_example/16_lsp_server.rb +56 -0
  288. data/tech_example/17_property_testing.rb +92 -0
  289. data/tech_example/18_enhanced_repl.rb +120 -0
  290. data/tech_example/19_security_fuzzing.rb +145 -0
  291. data/tech_example/NEW_FEATURES.md +169 -0
  292. data/tech_example/README.md +118 -0
  293. data/tech_example/schemas/api_response.dsl +123 -0
  294. data/tech_example/schemas/ecommerce.dsl +121 -0
  295. data/tech_example/schemas/twitter_dm.dsl +109 -0
  296. data/tech_example/schemas/user.dsl +36 -0
  297. data/vscode-extension/README.md +246 -0
  298. data/vscode-extension/language-configuration.json +31 -0
  299. data/vscode-extension/package.json +55 -0
  300. data/vscode-extension/snippets/fakedatadsl.json +198 -0
  301. data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
  302. metadata +478 -0
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+ require "monitor" # reentrant lock: read methods can be called from within locked write methods
7
+
8
+ module Synthra
9
+ # Data Contracts Registry
10
+ #
11
+ # Centralized registry for versioned schemas with:
12
+ # - Semantic versioning support
13
+ # - Publishing and deprecation
14
+ # - Compatibility checking
15
+ # - Change history tracking
16
+ #
17
+ # @example Publish a schema
18
+ # registry = Synthra::ContractsRegistry.new("contracts/")
19
+ # registry.publish("User", version: "1.0.0", schema: user_schema)
20
+ #
21
+ # @example Check compatibility
22
+ # registry.compatible?("User", "1.0.0", "2.0.0")
23
+ #
24
+ class ContractsRegistry
25
+ attr_reader :base_dir, :contracts
26
+
27
+ # Contract states
28
+ STATES = %i[draft published deprecated retired].freeze
29
+
30
+ # Create a new contracts registry
31
+ #
32
+ # @param base_dir [String] directory for storing contracts
33
+ #
34
+ def initialize(base_dir = "contracts")
35
+ @base_dir = base_dir
36
+ @contracts = {}
37
+ # Monitor (reentrant): read methods now lock too, and the already-locked write methods call
38
+ # them — a plain Mutex would self-deadlock on that nesting.
39
+ @mutex = Monitor.new
40
+
41
+ FileUtils.mkdir_p(@base_dir)
42
+ load_contracts
43
+ end
44
+
45
+ # Publish a new schema version
46
+ #
47
+ # @param name [String] schema name
48
+ # @param version [String] semantic version (e.g., "1.0.0")
49
+ # @param schema [Schema] the schema to publish
50
+ # @param changelog [String] optional changelog entry
51
+ # @return [Hash] published contract metadata
52
+ #
53
+ def publish(name, version:, schema:, changelog: nil)
54
+ @mutex.synchronize do
55
+ key = contract_key(name, version)
56
+
57
+ # Validate version format
58
+ validate_version!(version)
59
+
60
+ # Check if already published
61
+ if @contracts[key] && @contracts[key][:state] == :published
62
+ raise ContractError, "Contract #{key} is already published"
63
+ end
64
+
65
+ # Create contract
66
+ contract = {
67
+ name: name,
68
+ version: version,
69
+ state: :published,
70
+ schema_hash: hash_schema(schema),
71
+ fields: schema.fields.map { |f| field_signature(f) },
72
+ behaviors: schema.behaviors,
73
+ published_at: Time.now.iso8601,
74
+ changelog: changelog,
75
+ dsl_content: schema.respond_to?(:dsl_content) ? schema.dsl_content : nil
76
+ }
77
+
78
+ @contracts[key] = contract
79
+ save_contract(key, contract)
80
+
81
+ # Record history
82
+ record_history(name, version, :published, changelog)
83
+
84
+ contract
85
+ end
86
+ end
87
+
88
+ # Deprecate a schema version
89
+ #
90
+ # @param name [String] schema name
91
+ # @param version [String] version to deprecate
92
+ # @param sunset_date [Date] when the version will be retired
93
+ # @param message [String] deprecation message
94
+ #
95
+ def deprecate(name, version:, sunset_date: nil, message: nil)
96
+ @mutex.synchronize do
97
+ key = contract_key(name, version)
98
+ contract = @contracts[key]
99
+
100
+ raise ContractError, "Contract #{key} not found" unless contract
101
+
102
+ contract[:state] = :deprecated
103
+ contract[:deprecated_at] = Time.now.iso8601
104
+ contract[:sunset_date] = sunset_date&.iso8601
105
+ contract[:deprecation_message] = message
106
+
107
+ save_contract(key, contract)
108
+ record_history(name, version, :deprecated, message)
109
+ end
110
+ end
111
+
112
+ # Retire a schema version (no longer available)
113
+ #
114
+ # @param name [String] schema name
115
+ # @param version [String] version to retire
116
+ #
117
+ def retire(name, version:)
118
+ @mutex.synchronize do
119
+ key = contract_key(name, version)
120
+ contract = @contracts[key]
121
+
122
+ raise ContractError, "Contract #{key} not found" unless contract
123
+
124
+ contract[:state] = :retired
125
+ contract[:retired_at] = Time.now.iso8601
126
+
127
+ save_contract(key, contract)
128
+ record_history(name, version, :retired)
129
+ end
130
+ end
131
+
132
+ # Get a specific contract
133
+ #
134
+ # @param name [String] schema name
135
+ # @param version [String] version (nil for latest)
136
+ # @return [Hash, nil] contract or nil
137
+ #
138
+ def get(name, version: nil)
139
+ @mutex.synchronize do
140
+ if version
141
+ @contracts[contract_key(name, version)]
142
+ else
143
+ latest_version(name)
144
+ end
145
+ end
146
+ end
147
+
148
+ # Get the latest published version
149
+ #
150
+ # @param name [String] schema name
151
+ # @return [Hash, nil] latest contract or nil
152
+ #
153
+ def latest_version(name)
154
+ @mutex.synchronize do
155
+ versions = versions_for(name).select { |v| @contracts[contract_key(name, v)][:state] == :published }
156
+ next nil if versions.empty?
157
+
158
+ sorted = versions.sort_by { |v| Gem::Version.new(v) }
159
+ @contracts[contract_key(name, sorted.last)]
160
+ end
161
+ end
162
+
163
+ # Get all versions for a schema
164
+ #
165
+ # @param name [String] schema name
166
+ # @return [Array<String>] versions
167
+ #
168
+ def versions_for(name)
169
+ @mutex.synchronize do
170
+ @contracts.keys
171
+ .select { |k| k.start_with?("#{name}@") }
172
+ .map { |k| k.split("@").last }
173
+ .sort_by { |v| Gem::Version.new(v) rescue v }
174
+ end
175
+ end
176
+
177
+ # Check if two versions are compatible
178
+ #
179
+ # @param name [String] schema name
180
+ # @param old_version [String] old version
181
+ # @param new_version [String] new version
182
+ # @return [Hash] compatibility report
183
+ #
184
+ def compatible?(name, old_version, new_version)
185
+ @mutex.synchronize do
186
+ old_contract = @contracts[contract_key(name, old_version)]
187
+ new_contract = @contracts[contract_key(name, new_version)]
188
+
189
+ raise ContractError, "Contract #{name}@#{old_version} not found" unless old_contract
190
+ raise ContractError, "Contract #{name}@#{new_version} not found" unless new_contract
191
+
192
+ analyze_compatibility(old_contract, new_contract)
193
+ end
194
+ end
195
+
196
+ # List all contracts
197
+ #
198
+ # @param state [Symbol] filter by state (nil for all)
199
+ # @return [Array<Hash>] contracts
200
+ #
201
+ def list(state: nil)
202
+ @mutex.synchronize do
203
+ contracts = @contracts.values
204
+ contracts = contracts.select { |c| c[:state] == state } if state
205
+ contracts.sort_by { |c| [c[:name], Gem::Version.new(c[:version])] }
206
+ end
207
+ end
208
+
209
+ # List all unique schema names
210
+ #
211
+ # @return [Array<String>] schema names
212
+ #
213
+ def schema_names
214
+ @mutex.synchronize { @contracts.values.map { |c| c[:name] }.uniq.sort }
215
+ end
216
+
217
+ # Get change history for a schema
218
+ #
219
+ # @param name [String] schema name
220
+ # @return [Array<Hash>] history entries
221
+ #
222
+ def history(name)
223
+ @mutex.synchronize do
224
+ history_file = File.join(@base_dir, "history", "#{name}.json")
225
+ next [] unless File.exist?(history_file)
226
+
227
+ JSON.parse(File.read(history_file), symbolize_names: true)
228
+ end
229
+ end
230
+
231
+ # Validate a schema against a published contract
232
+ #
233
+ # @param name [String] schema name
234
+ # @param version [String] version
235
+ # @param schema [Schema] schema to validate
236
+ # @return [Hash] validation result
237
+ #
238
+ def validate(name, version:, schema:)
239
+ @mutex.synchronize do
240
+ contract = @contracts[contract_key(name, version)]
241
+ raise ContractError, "Contract #{name}@#{version} not found" unless contract
242
+
243
+ current_fields = schema.fields.map { |f| field_signature(f) }
244
+ contract_fields = contract[:fields]
245
+
246
+ {
247
+ valid: current_fields == contract_fields,
248
+ hash_match: hash_schema(schema) == contract[:schema_hash],
249
+ missing_fields: contract_fields - current_fields,
250
+ extra_fields: current_fields - contract_fields
251
+ }
252
+ end
253
+ end
254
+
255
+ # Export all contracts to a directory
256
+ #
257
+ # @param output_dir [String] output directory
258
+ #
259
+ def export(output_dir)
260
+ FileUtils.mkdir_p(output_dir)
261
+
262
+ @mutex.synchronize do
263
+ @contracts.each do |key, contract|
264
+ File.write(
265
+ File.join(output_dir, "#{key.gsub('@', '_v')}.json"),
266
+ JSON.pretty_generate(contract)
267
+ )
268
+ end
269
+ end
270
+ end
271
+
272
+ # Import contracts from a directory
273
+ #
274
+ # @param input_dir [String] input directory
275
+ #
276
+ def import(input_dir)
277
+ @mutex.synchronize do
278
+ Dir.glob(File.join(input_dir, "*.json")).each do |file|
279
+ contract = JSON.parse(File.read(file), symbolize_names: true)
280
+ key = contract_key(contract[:name], contract[:version])
281
+ @contracts[key] = contract
282
+ save_contract(key, contract)
283
+ end
284
+ end
285
+ end
286
+
287
+ private
288
+
289
+ def contract_key(name, version)
290
+ "#{name}@#{version}"
291
+ end
292
+
293
+ def validate_version!(version)
294
+ # \A..\z (not ^..$): ^/$ are line anchors in Ruby, so "1.0.0\n../../evil" would pass and then
295
+ # be used as a path fragment. \A..\z anchor the whole string and reject the embedded newline.
296
+ unless version.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?\z/)
297
+ raise ContractError, "Invalid version format: #{version}. Use semantic versioning (e.g., 1.0.0)"
298
+ end
299
+ end
300
+
301
+ def hash_schema(schema)
302
+ content = schema.fields.map { |f| "#{f.name}:#{f.type_name}:#{f.optional?}:#{f.nullable?}" }.join("|")
303
+ Digest::SHA256.hexdigest(content)[0, 16]
304
+ end
305
+
306
+ def field_signature(field)
307
+ {
308
+ name: field.name,
309
+ type: field.type_name,
310
+ optional: field.optional?,
311
+ nullable: field.nullable?,
312
+ args_hash: Digest::MD5.hexdigest(field.type_args.to_s)[0, 8]
313
+ }
314
+ end
315
+
316
+ def load_contracts
317
+ contracts_dir = File.join(@base_dir, "schemas")
318
+ return unless Dir.exist?(contracts_dir)
319
+
320
+ Dir.glob(File.join(contracts_dir, "*.json")).each do |file|
321
+ contract = JSON.parse(File.read(file), symbolize_names: true)
322
+ key = contract_key(contract[:name], contract[:version])
323
+ @contracts[key] = contract
324
+ end
325
+ end
326
+
327
+ def save_contract(key, contract)
328
+ contracts_dir = File.join(@base_dir, "schemas")
329
+ FileUtils.mkdir_p(contracts_dir)
330
+
331
+ filename = key.gsub("@", "_v") + ".json"
332
+ File.write(File.join(contracts_dir, filename), JSON.pretty_generate(contract))
333
+ end
334
+
335
+ def record_history(name, version, action, message = nil)
336
+ history_dir = File.join(@base_dir, "history")
337
+ FileUtils.mkdir_p(history_dir)
338
+
339
+ history_file = File.join(history_dir, "#{name}.json")
340
+ history = File.exist?(history_file) ? JSON.parse(File.read(history_file)) : []
341
+
342
+ history << {
343
+ "version" => version,
344
+ "action" => action.to_s,
345
+ "message" => message,
346
+ "timestamp" => Time.now.iso8601
347
+ }
348
+
349
+ File.write(history_file, JSON.pretty_generate(history))
350
+ end
351
+
352
+ def analyze_compatibility(old_contract, new_contract)
353
+ old_fields = old_contract[:fields].map { |f| f[:name] }
354
+ new_fields = new_contract[:fields].map { |f| f[:name] }
355
+
356
+ removed = old_fields - new_fields
357
+ added = new_fields - old_fields
358
+
359
+ # Check type changes
360
+ type_changes = []
361
+ (old_fields & new_fields).each do |name|
362
+ old_field = old_contract[:fields].find { |f| f[:name] == name }
363
+ new_field = new_contract[:fields].find { |f| f[:name] == name }
364
+
365
+ if old_field[:type] != new_field[:type]
366
+ type_changes << { field: name, from: old_field[:type], to: new_field[:type] }
367
+ end
368
+ end
369
+
370
+ # Check required changes
371
+ required_changes = []
372
+ (old_fields & new_fields).each do |name|
373
+ old_field = old_contract[:fields].find { |f| f[:name] == name }
374
+ new_field = new_contract[:fields].find { |f| f[:name] == name }
375
+
376
+ if old_field[:optional] && !new_field[:optional]
377
+ required_changes << { field: name, change: "optional → required" }
378
+ end
379
+ end
380
+
381
+ breaking = removed.any? || type_changes.any? || required_changes.any?
382
+
383
+ {
384
+ compatible: !breaking,
385
+ breaking: breaking,
386
+ changes: {
387
+ removed_fields: removed,
388
+ added_fields: added,
389
+ type_changes: type_changes,
390
+ required_changes: required_changes
391
+ },
392
+ summary: generate_compatibility_summary(removed, added, type_changes, required_changes)
393
+ }
394
+ end
395
+
396
+ def generate_compatibility_summary(removed, added, type_changes, required_changes)
397
+ parts = []
398
+ parts << "Removed fields: #{removed.join(', ')}" if removed.any?
399
+ parts << "Added fields: #{added.join(', ')}" if added.any?
400
+ type_changes.each { |c| parts << "Type changed: #{c[:field]} (#{c[:from]} → #{c[:to]})" }
401
+ required_changes.each { |c| parts << "Required changed: #{c[:field]} (#{c[:change]})" }
402
+ parts.empty? ? "No changes" : parts.join("; ")
403
+ end
404
+ end
405
+
406
+ # Contract error
407
+ class ContractError < StandardError; end
408
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct" # OpenStruct is used below; not a default gem on Ruby 3.5+/4.0, so require it.
4
+
5
+ module Synthra
6
+ # Database Seeder with Foreign Key Awareness
7
+ #
8
+ # Intelligently seeds databases respecting foreign key constraints
9
+ # and relationships between tables.
10
+ #
11
+ # @example Basic usage
12
+ # Synthra.seed_database do
13
+ # create User, count: 100
14
+ # create Order, count: 500 # Links to Users
15
+ # create OrderItem, count: 2000 # Links to Orders
16
+ # end
17
+ #
18
+ # @example With options
19
+ # Synthra.seed_database(truncate: true) do
20
+ # create User, count: 50, mode: :edge
21
+ # end
22
+ #
23
+ class DatabaseSeeder
24
+ attr_reader :registry, :created_records, :options
25
+
26
+ # Create a new seeder
27
+ #
28
+ # @param registry [Registry] schema registry
29
+ # @param options [Hash] seeder options
30
+ # @option options [Boolean] :truncate Truncate tables before seeding
31
+ # @option options [Boolean] :disable_callbacks Disable ActiveRecord callbacks
32
+ # @option options [Integer] :batch_size Batch insert size (default: 1000)
33
+ #
34
+ def initialize(registry: nil, **options)
35
+ @registry = registry || Synthra.instance_variable_get(:@default_registry) || Registry.new
36
+ @options = {
37
+ truncate: false,
38
+ disable_callbacks: false,
39
+ batch_size: 1000
40
+ }.merge(options)
41
+ @created_records = Hash.new { |h, k| h[k] = [] }
42
+ @foreign_key_cache = {}
43
+ end
44
+
45
+ # Execute seeding with DSL
46
+ #
47
+ # @yield seeding DSL block
48
+ #
49
+ def seed(&block)
50
+ refuse_in_production!
51
+ setup_environment
52
+ instance_eval(&block)
53
+
54
+ summary
55
+ end
56
+
57
+ # Create records for a model
58
+ #
59
+ # @param model [Class] ActiveRecord model class
60
+ # @param count [Integer] number of records
61
+ # @param mode [Symbol] generation mode
62
+ # @param overrides [Hash] field overrides
63
+ #
64
+ def create(model, count:, mode: :random, **overrides)
65
+ schema_name = model.respond_to?(:name) ? model.name : model.to_s
66
+ schema = find_or_infer_schema(schema_name, model)
67
+
68
+ puts "Creating #{count} #{schema_name} records..."
69
+
70
+ records = []
71
+ count.times do
72
+ data = generate_with_fk_resolution(schema, model, mode, overrides)
73
+ records << data
74
+ end
75
+
76
+ # Batch insert for performance — also the path for disable_callbacks: insert_all skips AR
77
+ # callbacks at the DB layer per-model, so we never mutate the global ActiveRecord::Base chain.
78
+ if model.respond_to?(:insert_all) && (@options[:batch_size] > 1 || @options[:disable_callbacks])
79
+ insert_in_batches(model, records)
80
+ else
81
+ records.each do |data|
82
+ record = model.create!(data)
83
+ @created_records[schema_name] << record
84
+ end
85
+ end
86
+
87
+ puts " ✓ Created #{count} #{schema_name} records"
88
+ end
89
+
90
+ # Create a single record and return it
91
+ #
92
+ # @param model [Class] ActiveRecord model
93
+ # @param mode [Symbol] generation mode
94
+ # @param overrides [Hash] field overrides
95
+ # @return [Object] created record
96
+ #
97
+ def create_one(model, mode: :random, **overrides)
98
+ schema_name = model.name
99
+ schema = find_or_infer_schema(schema_name, model)
100
+
101
+ data = generate_with_fk_resolution(schema, model, mode, overrides)
102
+ record = model.create!(data)
103
+ @created_records[schema_name] << record
104
+
105
+ record
106
+ end
107
+
108
+ # Get a random record from already created records
109
+ #
110
+ # @param model [Class] model class
111
+ # @return [Object, nil] random record or nil
112
+ #
113
+ def random_record(model)
114
+ records = @created_records[model.name]
115
+ records.sample if records.any?
116
+ end
117
+
118
+ # Get the ID of a random record
119
+ #
120
+ # @param model [Class] model class
121
+ # @return [Object, nil] random ID or nil
122
+ #
123
+ def random_id(model)
124
+ record = random_record(model)
125
+ record&.id
126
+ end
127
+
128
+ private
129
+
130
+ # SECURITY: the seeder truncates tables and (with disable_callbacks) flips
131
+ # ActiveRecord callbacks process-wide — catastrophic in production, especially
132
+ # multi-tenant where suppressed before_create callbacks (tenant scoping, audit)
133
+ # affect every concurrent request. Refuse to run in the production Rails env
134
+ # unless an operator explicitly overrides. Fail closed.
135
+ def refuse_in_production!
136
+ return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
137
+ return if ENV["ALLOW_PRODUCTION_SEED"] == "true"
138
+
139
+ raise Synthra::Errors::SecurityError,
140
+ "DatabaseSeeder will not run in production: it truncates tables and disables " \
141
+ "ActiveRecord callbacks process-wide. Set ALLOW_PRODUCTION_SEED=true to override."
142
+ end
143
+
144
+ def setup_environment
145
+ truncate_tables if @options[:truncate] && defined?(ActiveRecord)
146
+ # disable_callbacks is now honoured per-model via insert_all in #create (skips callbacks at the
147
+ # DB layer). We no longer call ActiveRecord::Base.skip_callback: mutating the global callback
148
+ # chain suppresses callbacks (tenant scoping, audit, webhooks) for every concurrent request.
149
+ end
150
+
151
+ def truncate_tables
152
+ return unless defined?(ActiveRecord)
153
+
154
+ refuse_in_production! # defense in depth — never TRUNCATE prod tables
155
+
156
+ # Disable foreign key checks temporarily
157
+ connection = ActiveRecord::Base.connection
158
+
159
+ case connection.adapter_name
160
+ when "PostgreSQL"
161
+ connection.execute("SET CONSTRAINTS ALL DEFERRED")
162
+ when "MySQL", "Mysql2"
163
+ connection.execute("SET FOREIGN_KEY_CHECKS = 0")
164
+ end
165
+
166
+ @created_records.keys.each do |table|
167
+ connection.truncate(table.tableize) rescue nil
168
+ end
169
+
170
+ case connection.adapter_name
171
+ when "PostgreSQL"
172
+ connection.execute("SET CONSTRAINTS ALL IMMEDIATE")
173
+ when "MySQL", "Mysql2"
174
+ connection.execute("SET FOREIGN_KEY_CHECKS = 1")
175
+ end
176
+ end
177
+
178
+ def find_or_infer_schema(name, model)
179
+ if @registry.schema?(name)
180
+ @registry.schema(name)
181
+ elsif model.respond_to?(:columns)
182
+ dsl = SchemaInference.from_active_record(model)
183
+ Synthra.parse(dsl)
184
+ else
185
+ raise ArgumentError, "No schema found for #{name} and model doesn't support inference"
186
+ end
187
+ end
188
+
189
+ def generate_with_fk_resolution(schema, model, mode, overrides)
190
+ # Detect foreign keys
191
+ fk_overrides = resolve_foreign_keys(model)
192
+
193
+ # Generate data
194
+ data = schema.generate(mode: mode, overrides: overrides.merge(fk_overrides))
195
+
196
+ # Filter to only include valid columns
197
+ filter_columns(model, data)
198
+ end
199
+
200
+ def resolve_foreign_keys(model)
201
+ return {} unless model.respond_to?(:reflect_on_all_associations)
202
+
203
+ fk_overrides = {}
204
+
205
+ model.reflect_on_all_associations(:belongs_to).each do |assoc|
206
+ fk_column = assoc.foreign_key
207
+ related_model = assoc.klass rescue next
208
+
209
+ # Get a random ID from created records
210
+ if (id = random_id(related_model))
211
+ fk_overrides[fk_column] = id
212
+ end
213
+ end
214
+
215
+ fk_overrides
216
+ end
217
+
218
+ def filter_columns(model, data)
219
+ return data unless model.respond_to?(:column_names)
220
+
221
+ data.select { |k, _| model.column_names.include?(k.to_s) }
222
+ end
223
+
224
+ def insert_in_batches(model, records)
225
+ batch_size = @options[:batch_size]
226
+
227
+ records.each_slice(batch_size) do |batch|
228
+ model.insert_all(batch)
229
+ end
230
+
231
+ # Store records for FK resolution (simplified - just IDs)
232
+ schema_name = model.name
233
+ records.each do |data|
234
+ @created_records[schema_name] << OpenStruct.new(data)
235
+ end
236
+ end
237
+
238
+ def summary
239
+ total = @created_records.values.flatten.count
240
+
241
+ puts "\n" + "=" * 50
242
+ puts "Seeding Complete!"
243
+ puts "=" * 50
244
+
245
+ @created_records.each do |model, records|
246
+ puts " #{model}: #{records.count} records"
247
+ end
248
+
249
+ puts "-" * 50
250
+ puts " Total: #{total} records"
251
+
252
+ @created_records
253
+ end
254
+ end
255
+
256
+ class << self
257
+ # Convenience method for database seeding
258
+ #
259
+ # @param options [Hash] seeder options
260
+ # @yield seeding DSL
261
+ # @return [Hash] created records by model
262
+ #
263
+ def seed_database(**options, &block)
264
+ seeder = DatabaseSeeder.new(**options)
265
+ seeder.seed(&block)
266
+ end
267
+ end
268
+ end