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.



61
62
63
# File 'lib/browserctl/migrations.rb', line 61

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.



101
102
103
# File 'lib/browserctl/migrations.rb', line 101

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.



110
111
112
113
114
115
116
# File 'lib/browserctl/migrations.rb', line 110

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).



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/browserctl/migrations.rb', line 74

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.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/browserctl/migrations.rb', line 44

def register(format:, from_version:, to_version:, &upgrade)
  unless upgrade
    raise Browserctl::Error.new(
      "upgrade block required",
      code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
      context: { dsl: :migrations, action: :register, format: format,
                 from_version: from_version, to_version: to_version }
    )
  end

  @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.



66
67
68
# File 'lib/browserctl/migrations.rb', line 66

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.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/browserctl/migrations.rb', line 128

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