pcrd 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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +614 -0
  5. data/bin/pcrd +7 -0
  6. data/lib/pcrd/advisory_lock.rb +50 -0
  7. data/lib/pcrd/apply/engine.rb +184 -0
  8. data/lib/pcrd/apply/worker.rb +97 -0
  9. data/lib/pcrd/backfill/batch.rb +158 -0
  10. data/lib/pcrd/backfill/engine.rb +153 -0
  11. data/lib/pcrd/checkpoint/store.rb +217 -0
  12. data/lib/pcrd/cli.rb +274 -0
  13. data/lib/pcrd/commands/analyze.rb +125 -0
  14. data/lib/pcrd/commands/cleanup.rb +112 -0
  15. data/lib/pcrd/commands/demo.rb +152 -0
  16. data/lib/pcrd/commands/readiness.rb +30 -0
  17. data/lib/pcrd/commands/status.rb +129 -0
  18. data/lib/pcrd/commands/verify.rb +172 -0
  19. data/lib/pcrd/config/add_column.rb +7 -0
  20. data/lib/pcrd/config/analyze_config.rb +8 -0
  21. data/lib/pcrd/config/column_spec.rb +10 -0
  22. data/lib/pcrd/config/connection.rb +7 -0
  23. data/lib/pcrd/config/cutover_config.rb +7 -0
  24. data/lib/pcrd/config/load_error.rb +7 -0
  25. data/lib/pcrd/config/loader.rb +158 -0
  26. data/lib/pcrd/config/migrate_config.rb +21 -0
  27. data/lib/pcrd/config/root.rb +9 -0
  28. data/lib/pcrd/config/schema.rb +62 -0
  29. data/lib/pcrd/config/table.rb +9 -0
  30. data/lib/pcrd/config/verify_config.rb +7 -0
  31. data/lib/pcrd/config.rb +7 -0
  32. data/lib/pcrd/connection/client.rb +129 -0
  33. data/lib/pcrd/connection/error.rb +7 -0
  34. data/lib/pcrd/connection/replication.rb +108 -0
  35. data/lib/pcrd/cutover/orchestrator.rb +108 -0
  36. data/lib/pcrd/cutover/sequences.rb +138 -0
  37. data/lib/pcrd/demo/generator.rb +214 -0
  38. data/lib/pcrd/demo/schema.rb +154 -0
  39. data/lib/pcrd/error.rb +12 -0
  40. data/lib/pcrd/migration/orchestrator.rb +272 -0
  41. data/lib/pcrd/monitor/lag.rb +107 -0
  42. data/lib/pcrd/options.rb +15 -0
  43. data/lib/pcrd/output/analyze_printer.rb +173 -0
  44. data/lib/pcrd/output/cutover_printer.rb +128 -0
  45. data/lib/pcrd/output/preflight_printer.rb +119 -0
  46. data/lib/pcrd/output/readiness_printer.rb +72 -0
  47. data/lib/pcrd/preflight.rb +331 -0
  48. data/lib/pcrd/readiness/manifest.rb +201 -0
  49. data/lib/pcrd/replication/consumer.rb +235 -0
  50. data/lib/pcrd/replication/error.rb +10 -0
  51. data/lib/pcrd/replication/pgoutput/messages.rb +68 -0
  52. data/lib/pcrd/replication/pgoutput/parser.rb +316 -0
  53. data/lib/pcrd/reporter/console.rb +46 -0
  54. data/lib/pcrd/reporter/null.rb +14 -0
  55. data/lib/pcrd/schema/column.rb +59 -0
  56. data/lib/pcrd/schema/ddl.rb +71 -0
  57. data/lib/pcrd/schema/diff_entry.rb +36 -0
  58. data/lib/pcrd/schema/differ.rb +175 -0
  59. data/lib/pcrd/schema/object_reader.rb +187 -0
  60. data/lib/pcrd/schema/packer.rb +90 -0
  61. data/lib/pcrd/schema/reader.rb +118 -0
  62. data/lib/pcrd/schema/setup.rb +143 -0
  63. data/lib/pcrd/schema/setup_error.rb +9 -0
  64. data/lib/pcrd/schema/table_not_found.rb +8 -0
  65. data/lib/pcrd/schema/type_registry.rb +116 -0
  66. data/lib/pcrd/sql.rb +55 -0
  67. data/lib/pcrd/transform/row_transformer.rb +69 -0
  68. data/lib/pcrd/transform/type_map.rb +209 -0
  69. data/lib/pcrd/transform/validator.rb +106 -0
  70. data/lib/pcrd/version.rb +5 -0
  71. data/lib/pcrd.rb +11 -0
  72. metadata +231 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Pcrd
6
+ module Output
7
+ class PreflightPrinter
8
+ PASTEL = Pastel.new
9
+
10
+ ICONS = {
11
+ pass: PASTEL.green("✓"),
12
+ fail: PASTEL.red("✗"),
13
+ warn: PASTEL.yellow("⚠"),
14
+ info: PASTEL.dim("·")
15
+ }.freeze
16
+
17
+ def initialize(output: $stdout)
18
+ @out = output
19
+ end
20
+
21
+ def print(result)
22
+ @out.puts
23
+ @out.puts PASTEL.bold("Preflight check")
24
+ @out.puts PASTEL.dim("─" * 70)
25
+ @out.puts
26
+
27
+ result.items.each { |item| print_item(item) }
28
+
29
+ @out.puts
30
+ print_ddl_section(result.ddl_map) if result.ddl_map.any?
31
+ print_estimate_section(result.row_counts, result) if result.row_counts.any?
32
+ print_summary(result)
33
+ end
34
+
35
+ private
36
+
37
+ def print_item(item)
38
+ icon = ICONS[item.status]
39
+ label = case item.status
40
+ when :fail then PASTEL.red(item.label)
41
+ when :warn then PASTEL.yellow(item.label)
42
+ else item.label
43
+ end
44
+
45
+ if item.detail&.include?("\n")
46
+ @out.puts " #{icon} #{label}"
47
+ item.detail.each_line do |line|
48
+ @out.puts " #{PASTEL.dim(line.chomp)}"
49
+ end
50
+ elsif item.detail
51
+ @out.puts " #{icon} #{label} #{PASTEL.dim(item.detail)}"
52
+ else
53
+ @out.puts " #{icon} #{label}"
54
+ end
55
+ end
56
+
57
+ def print_ddl_section(ddl_map)
58
+ @out.puts PASTEL.dim("─" * 70)
59
+ @out.puts
60
+ @out.puts PASTEL.bold(" Target DDL:")
61
+ @out.puts
62
+ ddl_map.each do |_table, ddl|
63
+ ddl.each_line { @out.puts " #{_1.chomp}" }
64
+ @out.puts " ;"
65
+ @out.puts
66
+ end
67
+ end
68
+
69
+ def print_estimate_section(row_counts, result)
70
+ return unless result.respond_to?(:passed)
71
+
72
+ batch_size = 10_000 # default; real config batch size used by migrate
73
+
74
+ @out.puts PASTEL.dim("─" * 70)
75
+ @out.puts
76
+ @out.puts PASTEL.bold(" Estimated backfill:")
77
+ @out.puts
78
+
79
+ row_counts.each do |table, count|
80
+ next if count.zero?
81
+
82
+ batches = (count.to_f / batch_size).ceil
83
+ est_mins = (batches / 100.0).round(1)
84
+ est_label = est_mins >= 60 ? "~#{(est_mins / 60).round(1)}h" : "~#{est_mins}m"
85
+
86
+ @out.puts " #{table}: #{format_count(count)} rows / " \
87
+ "#{format_count(batch_size)} per batch = " \
88
+ "#{format_count(batches)} batches (#{est_label} at 100 batches/min)"
89
+ end
90
+ @out.puts
91
+ end
92
+
93
+ def print_summary(result)
94
+ @out.puts PASTEL.dim("─" * 70)
95
+ @out.puts
96
+
97
+ fail_count = result.items.count { |i| i.status == :fail }
98
+ warn_count = result.items.count { |i| i.status == :warn }
99
+
100
+ if result.passed
101
+ if warn_count > 0
102
+ @out.puts " #{PASTEL.green("✓")} #{PASTEL.bold("All checks passed")} " \
103
+ "(#{PASTEL.yellow("#{warn_count} warning(s)")} — review before proceeding)"
104
+ else
105
+ @out.puts " #{PASTEL.green("✓")} #{PASTEL.bold("All checks passed.")}"
106
+ end
107
+ else
108
+ @out.puts " #{PASTEL.red("✗")} #{PASTEL.bold(PASTEL.red("#{fail_count} check(s) failed."))} " \
109
+ "Fix the issue(s) above before running migrate."
110
+ end
111
+ @out.puts
112
+ end
113
+
114
+ def format_count(n)
115
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Pcrd
6
+ module Output
7
+ # Renders a Readiness::Manifest::Result: a per-table checklist of secondary
8
+ # objects, followed by a single block of DDL to run on the target before
9
+ # cutover.
10
+ class ReadinessPrinter
11
+ PASTEL = Pastel.new
12
+
13
+ ICONS = {
14
+ present: PASTEL.green("✓"),
15
+ missing: PASTEL.yellow("+"),
16
+ needs_review: PASTEL.red("!"),
17
+ info: PASTEL.dim("·")
18
+ }.freeze
19
+
20
+ def initialize(output: $stdout)
21
+ @out = output
22
+ end
23
+
24
+ def print(result)
25
+ @out.puts
26
+ @out.puts PASTEL.bold("Target readiness")
27
+ @out.puts PASTEL.dim("─" * 70)
28
+
29
+ result.tables.each { |table| print_table(table) }
30
+
31
+ print_ddl_section(result)
32
+ print_legend
33
+ end
34
+
35
+ private
36
+
37
+ def print_table(table)
38
+ @out.puts
39
+ @out.puts PASTEL.bold(" #{table.table_name}")
40
+ if table.entries.empty?
41
+ @out.puts " #{PASTEL.dim('no secondary objects on source')}"
42
+ return
43
+ end
44
+
45
+ table.entries.each do |e|
46
+ @out.puts " #{ICONS[e.status]} #{e.category.ljust(10)} " \
47
+ "#{e.name.ljust(28)} #{PASTEL.dim(e.detail)}"
48
+ end
49
+ end
50
+
51
+ def print_ddl_section(result)
52
+ ddl = result.tables.flat_map { |t| t.entries }.filter_map(&:ddl)
53
+ return if ddl.empty?
54
+
55
+ @out.puts
56
+ @out.puts PASTEL.dim("─" * 70)
57
+ @out.puts
58
+ @out.puts PASTEL.bold(" DDL to run on the target before cutover:")
59
+ @out.puts
60
+ ddl.each { |stmt| @out.puts " #{stmt}" }
61
+ @out.puts
62
+ end
63
+
64
+ def print_legend
65
+ @out.puts PASTEL.dim("─" * 70)
66
+ @out.puts " #{ICONS[:present]} present #{ICONS[:missing]} will create " \
67
+ "#{ICONS[:needs_review]} needs review #{ICONS[:info]} handled at cutover"
68
+ @out.puts
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pcrd
4
+ # Runs all pre-migration safety checks and generates the target DDL.
5
+ #
6
+ # Checks are grouped and run top-to-bottom. Connection failures are hard
7
+ # stops (subsequent checks that need a connection are skipped). All table
8
+ # checks run even when earlier tables fail, so the operator sees all
9
+ # problems at once.
10
+ #
11
+ # Returns a Result that includes all check items and a DDL map for display.
12
+ class Preflight
13
+ # Individual check result.
14
+ Item = Data.define(:status, :label, :detail)
15
+ # status: :pass | :fail | :warn | :info
16
+
17
+ # Overall preflight result.
18
+ Result = Data.define(:passed, :items, :ddl_map, :row_counts)
19
+ # ddl_map: Hash<table_name, String> — generated CREATE TABLE SQL per table
20
+ # row_counts: Hash<table_name, Integer> — estimated row counts
21
+
22
+ HARD_FAIL = :fail # any :fail in items means Result#passed = false
23
+
24
+ def initialize(config, options = {})
25
+ @config = config
26
+ @options = Options.normalize(options)
27
+ @items = []
28
+ @ddl_map = {}
29
+ @row_counts = {}
30
+ end
31
+
32
+ def run
33
+ @source_pool = open_pool(@config.source)
34
+ @target_pool = @config.target ? open_pool(@config.target) : nil
35
+
36
+ check_source_connection
37
+ check_target_connection
38
+ check_wal_level
39
+ check_replication_slots
40
+ check_replication_objects
41
+
42
+ (@config.migrate&.tables || []).each { |t| check_table(t) }
43
+
44
+ Result.new(
45
+ passed: @items.none? { |i| i.status == :fail },
46
+ items: @items,
47
+ ddl_map: @ddl_map,
48
+ row_counts: @row_counts
49
+ )
50
+ ensure
51
+ @source_pool&.close
52
+ @target_pool&.close
53
+ end
54
+
55
+ private
56
+
57
+ # ── connection & server checks ──────────────────────────────────────────
58
+
59
+ def check_source_connection
60
+ @source_pool.exec("SELECT 1")
61
+ @source_ok = true
62
+ pass("source connection",
63
+ "#{@config.source.host}:#{@config.source.port}/#{@config.source.database}")
64
+ rescue Connection::Error => e
65
+ @source_ok = false
66
+ fail!("source connection", e.message)
67
+ end
68
+
69
+ def check_target_connection
70
+ return info("target connection", "not configured — skipping") unless @target_pool
71
+
72
+ @target_pool.exec("SELECT 1")
73
+ @target_ok = true
74
+ pass("target connection",
75
+ "#{@config.target.host}:#{@config.target.port}/#{@config.target.database}")
76
+ rescue Connection::Error => e
77
+ @target_ok = false
78
+ fail!("target connection", e.message)
79
+ end
80
+
81
+ def check_wal_level
82
+ return skip("wal_level", "source not reachable") unless @source_ok
83
+
84
+ result = @source_pool.exec("SELECT setting FROM pg_settings WHERE name = 'wal_level'")
85
+ level = result[0]["setting"]
86
+
87
+ if level == "logical"
88
+ pass("wal_level", "logical")
89
+ else
90
+ fail!("wal_level",
91
+ "#{level.inspect} — must be 'logical'; " \
92
+ "set wal_level = logical in postgresql.conf and restart PostgreSQL")
93
+ end
94
+ end
95
+
96
+ def check_replication_slots
97
+ return skip("replication slots", "source not reachable") unless @source_ok
98
+
99
+ max_row = @source_pool.exec("SELECT setting::int FROM pg_settings WHERE name = 'max_replication_slots'")
100
+ used_row = @source_pool.exec("SELECT count(*)::int FROM pg_replication_slots")
101
+ max_slots = max_row[0]["setting"].to_i
102
+ used = used_row[0]["count"].to_i
103
+ free = max_slots - used
104
+ # pcrd creates one logical slot per migration covering all tables (via a
105
+ # single publication), not one slot per table.
106
+ needed = 1
107
+
108
+ if free >= needed
109
+ pass("replication slots", "#{used} used / #{max_slots} max (#{free} free, #{needed} needed)")
110
+ else
111
+ fail!("replication slots",
112
+ "#{used} used / #{max_slots} max — need #{needed} free; " \
113
+ "increase max_replication_slots in postgresql.conf")
114
+ end
115
+ end
116
+
117
+ # Checks whether the slot/publication already exist, interpreted against
118
+ # --resume: a fresh run must not collide with leftovers, and a resume must
119
+ # have something to resume. This catches the conflict before any work.
120
+ def check_replication_objects
121
+ return unless @source_ok
122
+ return unless @config.migrate
123
+
124
+ slot = @config.migrate.replication_slot
125
+ resume = @options[:resume]
126
+ present = @source_pool.exec(
127
+ "SELECT 1 FROM pg_replication_slots WHERE slot_name = $1", [slot]
128
+ ).ntuples.positive?
129
+
130
+ if resume && !present
131
+ fail!("replication slot state",
132
+ "--resume given but slot '#{slot}' does not exist; " \
133
+ "run a fresh migration without --resume")
134
+ elsif resume
135
+ pass("replication slot state", "slot '#{slot}' present — will resume")
136
+ elsif present
137
+ fail!("replication slot state",
138
+ "slot '#{slot}' already exists from a previous run; resume it with --resume, " \
139
+ "or remove it with `pcrd cleanup` to start over")
140
+ else
141
+ pass("replication slot state", "no existing slot — will be created")
142
+ end
143
+ end
144
+
145
+ # ── per-table checks ────────────────────────────────────────────────────
146
+
147
+ def check_table(table_config)
148
+ return unless @source_ok
149
+
150
+ name = table_config.name
151
+ reader = Schema::Reader.new(@source_pool)
152
+
153
+ # 1. Source table exists
154
+ unless reader.table_exists?(name)
155
+ fail!("#{name}: source table", "table '#{name}' not found on source")
156
+ return
157
+ end
158
+
159
+ source_cols = reader.read(name)
160
+ row_count = reader.estimated_row_count(name)
161
+ pk_cols = reader.primary_key_columns(name)
162
+ @row_counts[name] = row_count
163
+
164
+ pass("#{name}: source table", "#{format_count(row_count)} rows")
165
+
166
+ # 2. Primary key required
167
+ if pk_cols.empty?
168
+ fail!("#{name}: primary key",
169
+ "no primary key found — pcrd requires a primary key for upsert " \
170
+ "semantics during the backfill/streaming overlap window")
171
+ else
172
+ pass("#{name}: primary key", pk_cols.join(", "))
173
+ check_pk_not_dropped(name, table_config, pk_cols)
174
+ check_replica_identity(name)
175
+ end
176
+
177
+ # 3. Target table must not exist (unless --force-overwrite)
178
+ check_target_table(name, table_config)
179
+
180
+ # 4. All spec columns exist on source
181
+ check_spec_columns_exist(name, table_config, source_cols)
182
+
183
+ # 5. All type casts are known + run data validation
184
+ check_type_casts(name, table_config, source_cols)
185
+
186
+ # 6. Generate DDL for display
187
+ @ddl_map[name] = Schema::DDL.generate(
188
+ source_columns: source_cols,
189
+ table_config: table_config,
190
+ primary_key_columns: pk_cols,
191
+ schema_name: "public"
192
+ )
193
+ rescue Connection::Error => e
194
+ fail!("#{name}", "database error: #{e.message}")
195
+ end
196
+
197
+ # A primary-key column cannot be dropped: the apply engine matches replayed
198
+ # UPDATE/DELETE events by primary key, so dropping one breaks streaming.
199
+ def check_pk_not_dropped(name, table_config, pk_cols)
200
+ dropped = pk_cols.select do |col|
201
+ spec = table_config.columns&.[](col) || table_config.columns&.[](col.to_sym)
202
+ spec&.drop
203
+ end
204
+ return if dropped.empty?
205
+
206
+ fail!("#{name}: primary key",
207
+ "primary key column(s) cannot be dropped: #{dropped.join(', ')} — " \
208
+ "they are required to match replicated updates and deletes")
209
+ end
210
+
211
+ # The source table's replica identity must carry key columns on UPDATE/DELETE
212
+ # or the apply engine cannot locate the target row to update/delete.
213
+ def check_replica_identity(name)
214
+ ident = Schema::Reader.new(@source_pool).replica_identity(name)
215
+
216
+ case ident
217
+ when "d" then pass("#{name}: replica identity", "default (primary key)")
218
+ when "f" then pass("#{name}: replica identity", "full")
219
+ when "i" then pass("#{name}: replica identity", "index")
220
+ when "n"
221
+ fail!("#{name}: replica identity",
222
+ "REPLICA IDENTITY NOTHING — UPDATE/DELETE WAL records carry no key " \
223
+ "columns, so replicated updates and deletes cannot be applied. " \
224
+ "Run: ALTER TABLE #{Sql.quote_table(name)} REPLICA IDENTITY DEFAULT (or FULL).")
225
+ else
226
+ warn("#{name}: replica identity", "unrecognized setting #{ident.inspect}")
227
+ end
228
+ end
229
+
230
+ def check_target_table(name, _table_config)
231
+ return unless @target_ok
232
+
233
+ reader = Schema::Reader.new(@target_pool)
234
+ if reader.table_exists?(name)
235
+ if @options[:"force-overwrite"]
236
+ warn("#{name}: target table", "already exists — will be dropped and recreated (--force-overwrite)")
237
+ else
238
+ fail!("#{name}: target table",
239
+ "table '#{name}' already exists on target; " \
240
+ "pass --force-overwrite to drop and recreate it")
241
+ end
242
+ else
243
+ pass("#{name}: target table", "does not exist — will be created")
244
+ end
245
+ end
246
+
247
+ def check_spec_columns_exist(name, table_config, source_cols)
248
+ source_names = source_cols.map(&:name)
249
+ missing = (table_config.columns || {}).keys.map(&:to_s) - source_names
250
+ if missing.any?
251
+ fail!("#{name}: column spec",
252
+ "column(s) in spec not found on source: #{missing.join(', ')}")
253
+ else
254
+ pass("#{name}: column spec", "all spec columns found on source")
255
+ end
256
+ end
257
+
258
+ def check_type_casts(name, table_config, source_cols)
259
+ col_index = source_cols.each_with_object({}) { |c, h| h[c.name] = c }
260
+ unknown = []
261
+ safe_changes = []
262
+ validator = Transform::Validator.new(@source_pool)
263
+ failures = validator.validate(table_config, source_cols)
264
+
265
+ (table_config.columns || {}).each do |src_name, col_spec|
266
+ next if col_spec.drop || col_spec.type.nil?
267
+
268
+ src_col = col_index[src_name.to_s]
269
+ next unless src_col
270
+
271
+ safety = Transform::TypeMap.cast_safety(src_col.type_name, col_spec.type)
272
+
273
+ if safety == :unsupported
274
+ unknown << "#{src_name}: #{src_col.display_type} → #{col_spec.type}"
275
+ else
276
+ label = [col_spec.rename ? "rename" : nil,
277
+ col_spec.type ? "#{src_col.display_type} → #{col_spec.type}" : nil,
278
+ "(#{safety.to_s.tr('_', ' ')})"].compact.join(" ")
279
+ safe_changes << "#{src_name}: #{label}"
280
+ end
281
+ end
282
+
283
+ if unknown.any?
284
+ fail!("#{name}: type casts",
285
+ "unsupported type transition(s):\n" +
286
+ unknown.map { " #{_1}" }.join("\n"))
287
+ return
288
+ end
289
+
290
+ if safe_changes.any?
291
+ pass("#{name}: type casts", safe_changes.join("\n" + " " * 6))
292
+ end
293
+
294
+ hard_fails = failures.reject(&:warn_only)
295
+ warnings = failures.select(&:warn_only)
296
+
297
+ if hard_fails.any?
298
+ msgs = hard_fails.map do |f|
299
+ "#{f.column_name} (#{f.source_type} → #{f.target_type}): " \
300
+ "#{f.failing_count} row(s) would fail — #{f.description}"
301
+ end
302
+ fail!("#{name}: data validation",
303
+ "#{hard_fails.length} cast(s) failed validation:\n" +
304
+ msgs.map { " #{_1}" }.join("\n"))
305
+ else
306
+ pass("#{name}: data validation", "all casts validated")
307
+ end
308
+
309
+ warnings.each do |w|
310
+ warn("#{name}: #{w.column_name}",
311
+ "#{w.source_type} → #{w.target_type}: #{w.description}")
312
+ end
313
+ end
314
+
315
+ # ── helpers ─────────────────────────────────────────────────────────────
316
+
317
+ def open_pool(conn_config)
318
+ Connection::Client.new(conn_config)
319
+ end
320
+
321
+ def pass(label, detail = nil) @items << Item.new(status: :pass, label: label, detail: detail) end
322
+ def fail!(label, detail) @items << Item.new(status: :fail, label: label, detail: detail) end
323
+ def warn(label, detail) @items << Item.new(status: :warn, label: label, detail: detail) end
324
+ def info(label, detail = nil) @items << Item.new(status: :info, label: label, detail: detail) end
325
+ def skip(label, reason) @items << Item.new(status: :info, label: label, detail: "skipped — #{reason}") end
326
+
327
+ def format_count(n)
328
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
329
+ end
330
+ end
331
+ end