Module: Browserctl::Migrations

Defined in:
lib/browserctl/migrations.rb

Overview

Migration registry for browserctl persisted formats. Operators run ‘browserctl migrate <path>` to upgrade an artifact written by an older browserctl to the current build’s format version.

Distinct from ‘verify_format_version!` (in bundle/recording/workflow): those gates stay strict — a reader that encounters an unknown version raises ProtocolMismatch. This module is the only blessed path to mutate an old artifact in place.

The registry ships empty in v0.12. The first real migration arrives only when a format actually changes post-1.0. See ‘docs/reference/format-versions.md` (“Migration registry”).

Defined Under Namespace

Classes: Migration, Result

Constant Summary collapse

FORMAT_EXTENSIONS =
{
  ".bctl" => :bundle,
  ".jsonl" => :recording,
  ".rb" => :workflow
}.freeze

Class Method Summary collapse

Class Method Details

.allObject

All registered migrations, in registration order.



54
55
56
# File 'lib/browserctl/migrations.rb', line 54

def all
  @mutex.synchronize { @registry.dup }
end

.detect_format(path) ⇒ Object

Inspects an artifact path and returns a format symbol — ‘:bundle`, `:recording`, or `:workflow` — or `nil` when the format cannot be identified. Detection is extension-driven: keep new formats listed in FORMAT_EXTENSIONS so this stays a one-line lookup.



94
95
96
# File 'lib/browserctl/migrations.rb', line 94

def detect_format(path)
  FORMAT_EXTENSIONS[File.extname(path.to_s).downcase]
end

.detect_version(path, format) ⇒ Object

Reads ‘path` and returns the integer format_version it declares, or `nil` when no version header is present. Format-specific because each format stores the header differently — the bundle in its binary manifest, the recording in a `_meta` JSONL line, the workflow in a Ruby comment.



103
104
105
106
107
108
109
# File 'lib/browserctl/migrations.rb', line 103

def detect_version(path, format)
  case format
  when :bundle    then peek_bundle_version(path)
  when :recording then peek_recording_version(path)
  when :workflow  then peek_workflow_version(path)
  end
end

.find_path(format:, from:, to:) ⇒ Object

Breadth-first search through registered migrations to chain a path for ‘format` from `from` to `to`. Returns the ordered list of Migration hops, or `nil` if no path is reachable. When `from == to` returns an empty array (already at target — no work to do).



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/browserctl/migrations.rb', line 67

def find_path(format:, from:, to:)
  return [] if from == to

  all_for_format = all.select { |m| m.format == format }
  queue = [[from, []]]
  seen  = { from => true }

  until queue.empty?
    current, path = queue.shift
    all_for_format.each do |m|
      next unless m.from_version == current
      next if seen[m.to_version]

      new_path = path + [m]
      return new_path if m.to_version == to

      seen[m.to_version] = true
      queue << [m.to_version, new_path]
    end
  end
  nil
end

.register(format:, from_version:, to_version:, &upgrade) ⇒ Object

Registers an upgrader for one hop in ‘format`’s version chain. The block receives the file path as a keyword argument and must rewrite the file in place to the new version.

Raises:

  • (ArgumentError)


44
45
46
47
48
49
50
51
# File 'lib/browserctl/migrations.rb', line 44

def register(format:, from_version:, to_version:, &upgrade)
  raise ArgumentError, "upgrade block required" unless upgrade

  @mutex.synchronize do
    @registry << Migration.new(format: format, from_version: from_version,
                               to_version: to_version, upgrade: upgrade)
  end
end

.reset!Object

Test-only hook — clears the registry. Not part of the public API.



59
60
61
# File 'lib/browserctl/migrations.rb', line 59

def reset!
  @mutex.synchronize { @registry.clear }
end

.run(path, target_version: nil) ⇒ Object

End-to-end migration. Detects format and current version, finds a chain of registered upgraders to ‘target_version` (or the latest `to_version` seen for this format if `target_version` is nil), and invokes each in order. Each upgrader rewrites the file in place.

Returns a Result. When no migrations are needed (or the registry has no entries for this format), ‘applied` is empty.

Raises ProtocolMismatch when format detection fails, the version cannot be read, or no chain reaches the target.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/browserctl/migrations.rb', line 121

def run(path, target_version: nil)
  format  = detect_format(path) or raise_protocol("could not detect format for #{path}")
  current = detect_version(path, format) or raise_protocol("could not read format_version from #{path}")
  target  = target_version || latest_known_target(format, current)

  if current == target
    # If we'd be a no-op but the artifact's declared version is one this
    # build's reader does not support, surface that as PROTOCOL_MISMATCH —
    # there is no migration registered to bring it into range.
    unless format_version_supported?(format, current)
      raise_protocol("#{format} at #{path} declares unsupported format_version=#{current}; " \
                     "no migration registered")
    end
    return Result.new(format: format, from: current, to: current, applied: [])
  end

  chain = find_path(format: format, from: current, to: target)
  raise_protocol("no migration path from #{format} v#{current} to v#{target}") if chain.nil?

  chain.each { |m| m.upgrade.call(path: path, from_version: m.from_version, to_version: m.to_version) }
  Result.new(format: format, from: current, to: target, applied: chain)
end