Class: Synthra::ContractsRegistry

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/contracts_registry.rb

Overview

Data Contracts Registry

Centralized registry for versioned schemas with:

  • Semantic versioning support
  • Publishing and deprecation
  • Compatibility checking
  • Change history tracking

Examples:

Publish a schema

registry = Synthra::ContractsRegistry.new("contracts/")
registry.publish("User", version: "1.0.0", schema: user_schema)

Check compatibility

registry.compatible?("User", "1.0.0", "2.0.0")

Constant Summary collapse

STATES =

Contract states

%i[draft published deprecated retired].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_dir = "contracts") ⇒ ContractsRegistry

Create a new contracts registry

Parameters:

  • base_dir (String) (defaults to: "contracts")

    directory for storing contracts



34
35
36
37
38
39
40
41
42
43
# File 'lib/synthra/contracts_registry.rb', line 34

def initialize(base_dir = "contracts")
  @base_dir = base_dir
  @contracts = {}
  # Monitor (reentrant): read methods now lock too, and the already-locked write methods call
  # them — a plain Mutex would self-deadlock on that nesting.
  @mutex = Monitor.new
  
  FileUtils.mkdir_p(@base_dir)
  load_contracts
end

Instance Attribute Details

#base_dirObject (readonly)

Returns the value of attribute base_dir.



25
26
27
# File 'lib/synthra/contracts_registry.rb', line 25

def base_dir
  @base_dir
end

#contractsObject (readonly)

Returns the value of attribute contracts.



25
26
27
# File 'lib/synthra/contracts_registry.rb', line 25

def contracts
  @contracts
end

Instance Method Details

#analyze_compatibility(old_contract, new_contract) ⇒ Object (private)



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
# File 'lib/synthra/contracts_registry.rb', line 352

def analyze_compatibility(old_contract, new_contract)
  old_fields = old_contract[:fields].map { |f| f[:name] }
  new_fields = new_contract[:fields].map { |f| f[:name] }
  
  removed = old_fields - new_fields
  added = new_fields - old_fields
  
  # Check type changes
  type_changes = []
  (old_fields & new_fields).each do |name|
    old_field = old_contract[:fields].find { |f| f[:name] == name }
    new_field = new_contract[:fields].find { |f| f[:name] == name }
    
    if old_field[:type] != new_field[:type]
      type_changes << { field: name, from: old_field[:type], to: new_field[:type] }
    end
  end
  
  # Check required changes
  required_changes = []
  (old_fields & new_fields).each do |name|
    old_field = old_contract[:fields].find { |f| f[:name] == name }
    new_field = new_contract[:fields].find { |f| f[:name] == name }
    
    if old_field[:optional] && !new_field[:optional]
      required_changes << { field: name, change: "optional → required" }
    end
  end
  
  breaking = removed.any? || type_changes.any? || required_changes.any?
  
  {
    compatible: !breaking,
    breaking: breaking,
    changes: {
      removed_fields: removed,
      added_fields: added,
      type_changes: type_changes,
      required_changes: required_changes
    },
    summary: generate_compatibility_summary(removed, added, type_changes, required_changes)
  }
end

#compatible?(name, old_version, new_version) ⇒ Hash

Check if two versions are compatible

Parameters:

  • name (String)

    schema name

  • old_version (String)

    old version

  • new_version (String)

    new version

Returns:

  • (Hash)

    compatibility report



184
185
186
187
188
189
190
191
192
193
194
# File 'lib/synthra/contracts_registry.rb', line 184

def compatible?(name, old_version, new_version)
  @mutex.synchronize do
    old_contract = @contracts[contract_key(name, old_version)]
    new_contract = @contracts[contract_key(name, new_version)]

    raise ContractError, "Contract #{name}@#{old_version} not found" unless old_contract
    raise ContractError, "Contract #{name}@#{new_version} not found" unless new_contract

    analyze_compatibility(old_contract, new_contract)
  end
end

#contract_key(name, version) ⇒ Object (private)



289
290
291
# File 'lib/synthra/contracts_registry.rb', line 289

def contract_key(name, version)
  "#{name}@#{version}"
end

#deprecate(name, version:, sunset_date: nil, message: nil) ⇒ Object

Deprecate a schema version

Parameters:

  • name (String)

    schema name

  • version (String)

    version to deprecate

  • sunset_date (Date) (defaults to: nil)

    when the version will be retired

  • message (String) (defaults to: nil)

    deprecation message



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/synthra/contracts_registry.rb', line 95

def deprecate(name, version:, sunset_date: nil, message: nil)
  @mutex.synchronize do
    key = contract_key(name, version)
    contract = @contracts[key]
    
    raise ContractError, "Contract #{key} not found" unless contract
    
    contract[:state] = :deprecated
    contract[:deprecated_at] = Time.now.iso8601
    contract[:sunset_date] = sunset_date&.iso8601
    contract[:deprecation_message] = message
    
    save_contract(key, contract)
    record_history(name, version, :deprecated, message)
  end
end

#export(output_dir) ⇒ Object

Export all contracts to a directory

Parameters:

  • output_dir (String)

    output directory



259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/synthra/contracts_registry.rb', line 259

def export(output_dir)
  FileUtils.mkdir_p(output_dir)

  @mutex.synchronize do
    @contracts.each do |key, contract|
      File.write(
        File.join(output_dir, "#{key.gsub('@', '_v')}.json"),
        JSON.pretty_generate(contract)
      )
    end
  end
end

#field_signature(field) ⇒ Object (private)



306
307
308
309
310
311
312
313
314
# File 'lib/synthra/contracts_registry.rb', line 306

def field_signature(field)
  {
    name: field.name,
    type: field.type_name,
    optional: field.optional?,
    nullable: field.nullable?,
    args_hash: Digest::MD5.hexdigest(field.type_args.to_s)[0, 8]
  }
end

#generate_compatibility_summary(removed, added, type_changes, required_changes) ⇒ Object (private)



396
397
398
399
400
401
402
403
# File 'lib/synthra/contracts_registry.rb', line 396

def generate_compatibility_summary(removed, added, type_changes, required_changes)
  parts = []
  parts << "Removed fields: #{removed.join(', ')}" if removed.any?
  parts << "Added fields: #{added.join(', ')}" if added.any?
  type_changes.each { |c| parts << "Type changed: #{c[:field]} (#{c[:from]}#{c[:to]})" }
  required_changes.each { |c| parts << "Required changed: #{c[:field]} (#{c[:change]})" }
  parts.empty? ? "No changes" : parts.join("; ")
end

#get(name, version: nil) ⇒ Hash?

Get a specific contract

Parameters:

  • name (String)

    schema name

  • version (String) (defaults to: nil)

    version (nil for latest)

Returns:

  • (Hash, nil)

    contract or nil



138
139
140
141
142
143
144
145
146
# File 'lib/synthra/contracts_registry.rb', line 138

def get(name, version: nil)
  @mutex.synchronize do
    if version
      @contracts[contract_key(name, version)]
    else
      latest_version(name)
    end
  end
end

#hash_schema(schema) ⇒ Object (private)



301
302
303
304
# File 'lib/synthra/contracts_registry.rb', line 301

def hash_schema(schema)
  content = schema.fields.map { |f| "#{f.name}:#{f.type_name}:#{f.optional?}:#{f.nullable?}" }.join("|")
  Digest::SHA256.hexdigest(content)[0, 16]
end

#history(name) ⇒ Array<Hash>

Get change history for a schema

Parameters:

  • name (String)

    schema name

Returns:

  • (Array<Hash>)

    history entries



222
223
224
225
226
227
228
229
# File 'lib/synthra/contracts_registry.rb', line 222

def history(name)
  @mutex.synchronize do
    history_file = File.join(@base_dir, "history", "#{name}.json")
    next [] unless File.exist?(history_file)

    JSON.parse(File.read(history_file), symbolize_names: true)
  end
end

#import(input_dir) ⇒ Object

Import contracts from a directory

Parameters:

  • input_dir (String)

    input directory



276
277
278
279
280
281
282
283
284
285
# File 'lib/synthra/contracts_registry.rb', line 276

def import(input_dir)
  @mutex.synchronize do
    Dir.glob(File.join(input_dir, "*.json")).each do |file|
      contract = JSON.parse(File.read(file), symbolize_names: true)
      key = contract_key(contract[:name], contract[:version])
      @contracts[key] = contract
      save_contract(key, contract)
    end
  end
end

#latest_version(name) ⇒ Hash?

Get the latest published version

Parameters:

  • name (String)

    schema name

Returns:

  • (Hash, nil)

    latest contract or nil



153
154
155
156
157
158
159
160
161
# File 'lib/synthra/contracts_registry.rb', line 153

def latest_version(name)
  @mutex.synchronize do
    versions = versions_for(name).select { |v| @contracts[contract_key(name, v)][:state] == :published }
    next nil if versions.empty?

    sorted = versions.sort_by { |v| Gem::Version.new(v) }
    @contracts[contract_key(name, sorted.last)]
  end
end

#list(state: nil) ⇒ Array<Hash>

List all contracts

Parameters:

  • state (Symbol) (defaults to: nil)

    filter by state (nil for all)

Returns:

  • (Array<Hash>)

    contracts



201
202
203
204
205
206
207
# File 'lib/synthra/contracts_registry.rb', line 201

def list(state: nil)
  @mutex.synchronize do
    contracts = @contracts.values
    contracts = contracts.select { |c| c[:state] == state } if state
    contracts.sort_by { |c| [c[:name], Gem::Version.new(c[:version])] }
  end
end

#load_contractsObject (private)



316
317
318
319
320
321
322
323
324
325
# File 'lib/synthra/contracts_registry.rb', line 316

def load_contracts
  contracts_dir = File.join(@base_dir, "schemas")
  return unless Dir.exist?(contracts_dir)
  
  Dir.glob(File.join(contracts_dir, "*.json")).each do |file|
    contract = JSON.parse(File.read(file), symbolize_names: true)
    key = contract_key(contract[:name], contract[:version])
    @contracts[key] = contract
  end
end

#publish(name, version:, schema:, changelog: nil) ⇒ Hash

Publish a new schema version

Parameters:

  • name (String)

    schema name

  • version (String)

    semantic version (e.g., "1.0.0")

  • schema (Schema)

    the schema to publish

  • changelog (String) (defaults to: nil)

    optional changelog entry

Returns:

  • (Hash)

    published contract metadata



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/synthra/contracts_registry.rb', line 53

def publish(name, version:, schema:, changelog: nil)
  @mutex.synchronize do
    key = contract_key(name, version)
    
    # Validate version format
    validate_version!(version)
    
    # Check if already published
    if @contracts[key] && @contracts[key][:state] == :published
      raise ContractError, "Contract #{key} is already published"
    end
    
    # Create contract
    contract = {
      name: name,
      version: version,
      state: :published,
      schema_hash: hash_schema(schema),
      fields: schema.fields.map { |f| field_signature(f) },
      behaviors: schema.behaviors,
      published_at: Time.now.iso8601,
      changelog: changelog,
      dsl_content: schema.respond_to?(:dsl_content) ? schema.dsl_content : nil
    }
    
    @contracts[key] = contract
    save_contract(key, contract)
    
    # Record history
    record_history(name, version, :published, changelog)
    
    contract
  end
end

#record_history(name, version, action, message = nil) ⇒ Object (private)



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/synthra/contracts_registry.rb', line 335

def record_history(name, version, action, message = nil)
  history_dir = File.join(@base_dir, "history")
  FileUtils.mkdir_p(history_dir)
  
  history_file = File.join(history_dir, "#{name}.json")
  history = File.exist?(history_file) ? JSON.parse(File.read(history_file)) : []
  
  history << {
    "version" => version,
    "action" => action.to_s,
    "message" => message,
    "timestamp" => Time.now.iso8601
  }
  
  File.write(history_file, JSON.pretty_generate(history))
end

#retire(name, version:) ⇒ Object

Retire a schema version (no longer available)

Parameters:

  • name (String)

    schema name

  • version (String)

    version to retire



117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/synthra/contracts_registry.rb', line 117

def retire(name, version:)
  @mutex.synchronize do
    key = contract_key(name, version)
    contract = @contracts[key]
    
    raise ContractError, "Contract #{key} not found" unless contract
    
    contract[:state] = :retired
    contract[:retired_at] = Time.now.iso8601
    
    save_contract(key, contract)
    record_history(name, version, :retired)
  end
end

#save_contract(key, contract) ⇒ Object (private)



327
328
329
330
331
332
333
# File 'lib/synthra/contracts_registry.rb', line 327

def save_contract(key, contract)
  contracts_dir = File.join(@base_dir, "schemas")
  FileUtils.mkdir_p(contracts_dir)
  
  filename = key.gsub("@", "_v") + ".json"
  File.write(File.join(contracts_dir, filename), JSON.pretty_generate(contract))
end

#schema_namesArray<String>

List all unique schema names

Returns:

  • (Array<String>)

    schema names



213
214
215
# File 'lib/synthra/contracts_registry.rb', line 213

def schema_names
  @mutex.synchronize { @contracts.values.map { |c| c[:name] }.uniq.sort }
end

#validate(name, version:, schema:) ⇒ Hash

Validate a schema against a published contract

Parameters:

  • name (String)

    schema name

  • version (String)

    version

  • schema (Schema)

    schema to validate

Returns:

  • (Hash)

    validation result



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/synthra/contracts_registry.rb', line 238

def validate(name, version:, schema:)
  @mutex.synchronize do
    contract = @contracts[contract_key(name, version)]
    raise ContractError, "Contract #{name}@#{version} not found" unless contract

    current_fields = schema.fields.map { |f| field_signature(f) }
    contract_fields = contract[:fields]

    {
      valid: current_fields == contract_fields,
      hash_match: hash_schema(schema) == contract[:schema_hash],
      missing_fields: contract_fields - current_fields,
      extra_fields: current_fields - contract_fields
    }
  end
end

#validate_version!(version) ⇒ Object (private)



293
294
295
296
297
298
299
# File 'lib/synthra/contracts_registry.rb', line 293

def validate_version!(version)
  # \A..\z (not ^..$): ^/$ are line anchors in Ruby, so "1.0.0\n../../evil" would pass and then
  # be used as a path fragment. \A..\z anchor the whole string and reject the embedded newline.
  unless version.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?\z/)
    raise ContractError, "Invalid version format: #{version}. Use semantic versioning (e.g., 1.0.0)"
  end
end

#versions_for(name) ⇒ Array<String>

Get all versions for a schema

Parameters:

  • name (String)

    schema name

Returns:

  • (Array<String>)

    versions



168
169
170
171
172
173
174
175
# File 'lib/synthra/contracts_registry.rb', line 168

def versions_for(name)
  @mutex.synchronize do
    @contracts.keys
      .select { |k| k.start_with?("#{name}@") }
      .map { |k| k.split("@").last }
      .sort_by { |v| Gem::Version.new(v) rescue v }
  end
end