reeve 0.6.0 → 0.8.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +293 -3
  3. data/README.md +276 -11
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
  10. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  11. data/lib/reeve/audit/chain/canonical.rb +38 -7
  12. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  13. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  14. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  15. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  16. data/lib/reeve/audit/chain/epochs.rb +83 -0
  17. data/lib/reeve/audit/chain/head.rb +55 -2
  18. data/lib/reeve/audit/chain/keyring.rb +6 -0
  19. data/lib/reeve/audit/chain/report.rb +232 -32
  20. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  21. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  22. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  23. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  24. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  25. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  26. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  27. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  28. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  29. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  30. data/lib/reeve/audit/chain/rotation.rb +124 -0
  31. data/lib/reeve/audit/chain/schema.rb +59 -0
  32. data/lib/reeve/audit/chain/verification.rb +53 -10
  33. data/lib/reeve/audit/chain/verifier.rb +75 -22
  34. data/lib/reeve/audit/chain/walk.rb +24 -4
  35. data/lib/reeve/audit/chain.rb +61 -13
  36. data/lib/reeve/audit/chain_epoch.rb +24 -0
  37. data/lib/reeve/audit/chain_record.rb +14 -8
  38. data/lib/reeve/audit/file_anchor.rb +49 -0
  39. data/lib/reeve/audit/retention_record.rb +24 -0
  40. data/lib/reeve/audit.rb +30 -3
  41. data/lib/reeve/configuration.rb +25 -0
  42. data/lib/reeve/errors.rb +15 -0
  43. data/lib/reeve/railtie.rb +2 -1
  44. data/lib/reeve/tasks/descriptions.rb +22 -0
  45. data/lib/reeve/tasks/purge_options.rb +54 -0
  46. data/lib/reeve/tasks.rb +104 -7
  47. data/lib/reeve/testing/checks/contract_version.rb +2 -2
  48. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  49. data/lib/reeve/testing/ledger.rb +19 -3
  50. data/lib/reeve/version.rb +1 -1
  51. metadata +28 -2
@@ -18,6 +18,17 @@ module Reeve
18
18
  # engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
19
19
  # SELECT ... FOR UPDATE.
20
20
  class Head
21
+ LOCKING_READ_ADAPTERS = /mysql|trilogy/i
22
+
23
+ # A read made under the head's lock must see the latest committed rows. PostgreSQL
24
+ # and SQLite read them anyway; InnoDB at REPEATABLE READ serves a plain SELECT from
25
+ # the snapshot of the caller's transaction, which a writer that committed while this
26
+ # one queued for the lock is not in (code review, specs/003). A locking read is
27
+ # current; LOCK IN SHARE MODE is the spelling every MySQL version accepts.
28
+ def self.current(connection, sql)
29
+ connection.adapter_name.match?(LOCKING_READ_ADAPTERS) ? "#{sql} LOCK IN SHARE MODE" : sql
30
+ end
31
+
21
32
  def initialize(connection:, entry_class:)
22
33
  @connection = connection
23
34
  @entry_class = entry_class
@@ -35,7 +46,7 @@ module Reeve
35
46
  # So a started chain can exist with nothing in it yet (head 0) — if the first chained
36
47
  # write then fails, say. The verifier reports that as no chain, not as a pass.
37
48
  def start!(signer)
38
- return if connection.select_value("SELECT 1 FROM #{TABLE} WHERE #{named}")
49
+ return if started?
39
50
 
40
51
  genesis = genesis_for(signer)
41
52
  digest = Canonical.genesis_digest(signer.signing_secret, genesis)
@@ -62,14 +73,56 @@ module Reeve
62
73
  [Integer(head["head_sequence"]), head["head_digest"]]
63
74
  end
64
75
 
76
+ # Takes the head's lock without consuming a sequence (specs/003 FR-008) and returns the
77
+ # head: a rotation queues at the same point as a write, so the epoch it opens begins
78
+ # exactly one past the last row any writer committed. A write that changes nothing
79
+ # still takes the row lock on PostgreSQL and MySQL and the write lock on SQLite.
80
+ def lock!
81
+ updated = connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence " \
82
+ "WHERE #{named}")
83
+ raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if updated.zero?
84
+
85
+ sql = "SELECT head_sequence FROM #{TABLE} WHERE #{named}"
86
+ Integer(connection.select_value(Head.current(connection, sql)))
87
+ end
88
+
89
+ # A Checkpoint of the head and the latest epoch, read under #lock!, which a write holds
90
+ # while it moves the head and opens its epoch, so the two are one committed state
91
+ # (specs/004 FR-005, research R2). Nil when the chain has not started or holds nothing.
92
+ def checkpoint(signer)
93
+ return nil unless started?
94
+
95
+ entry_class.transaction(requires_new: true) do
96
+ next nil unless lock!.positive?
97
+
98
+ Checkpoint.build(state: state, epoch: Epochs.new(connection: connection).latest,
99
+ keyring: signer)
100
+ end
101
+ end
102
+
103
+ # The chain record's genesis and head as one current read.
104
+ def state
105
+ sql = "SELECT genesis_digest, pre_chain_count, pre_chain_unchained_count, " \
106
+ "head_sequence, head_digest FROM #{TABLE} WHERE #{named}"
107
+ connection.select_one(Head.current(connection, sql))
108
+ end
109
+
110
+ def started?
111
+ !connection.select_value("SELECT 1 FROM #{TABLE} WHERE #{named}").nil?
112
+ end
113
+
65
114
  def point_at!(digest)
66
115
  connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
67
116
  "WHERE #{named}")
68
117
  end
69
118
 
119
+ # The connection this append runs on; Epochs uses it so the epoch an append opens
120
+ # commits or rolls back with the append.
121
+ attr_reader :connection
122
+
70
123
  private
71
124
 
72
- attr_reader :connection, :entry_class
125
+ attr_reader :entry_class
73
126
 
74
127
  def increment
75
128
  connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
@@ -16,6 +16,12 @@ module Reeve
16
16
  new(signing: config.ledger_key, retired: config.retired_ledger_keys)
17
17
  end
18
18
 
19
+ # The configured keys, or only `keys` when given: how an auditor verifies with their
20
+ # own copy.
21
+ def self.verifying(keys)
22
+ keys ? new(signing: nil, retired: keys) : from(Reeve.config)
23
+ end
24
+
19
25
  def initialize(signing:, retired: [])
20
26
  @signing_secret = signing
21
27
  @by_id = {}
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Reeve
4
6
  module Audit
5
7
  class Chain
@@ -15,6 +17,7 @@ module Reeve
15
17
  LABEL_WIDTH = 14
16
18
  KIND_WIDTH = 17
17
19
  PREFIX = "reeve ledger:"
20
+ NOTHING_AFTER_PURGE = "no entries after the purge to verify"
18
21
 
19
22
  def self.number(value)
20
23
  digits = value.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
@@ -27,10 +30,11 @@ module Reeve
27
30
 
28
31
  def to_s(limit:)
29
32
  listed = @v.breaks.reject { |found| found.kind == :unknown_key }
30
- return summary if listed.empty?
33
+ epochs = EpochLines.new(@v).to_a
34
+ return [summary, *epochs].join("\n") if listed.empty?
31
35
 
32
36
  lines = listed.first(limit).map { |found| line_for(found) }
33
- [summary, totals, *lines, more(listed, limit)].compact.join("\n")
37
+ [summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
34
38
  end
35
39
 
36
40
  private
@@ -45,48 +49,50 @@ module Reeve
45
49
  end
46
50
 
47
51
  def intact
48
- "#{PREFIX} INTACT — sequences #{range(@v.covered)} verified " \
49
- "(head at start: #{number(@v.head_at_start)}); #{uncovered}"
52
+ if @v.walked_from
53
+ return "#{PREFIX} INTACT — #{anchor_text.walked_from}; #{uncovered}; #{anchor_text}"
54
+ end
55
+
56
+ walked = @v.covered ? "sequences #{range(@v.covered)} verified" : NOTHING_AFTER_PURGE
57
+ "#{PREFIX} INTACT — #{walked} (head at start: #{number(@v.head_at_start)}); " \
58
+ "#{uncovered}; #{anchor_text}"
50
59
  end
51
60
 
52
61
  def broken
53
62
  count = @v.breaks.size
63
+ breaks = "#{number(count)} break#{'s' unless count == 1}"
64
+ if anchor_text.emptied?
65
+ return "#{PREFIX} BROKEN — #{breaks} in the ledger; #{anchor_text.emptied}"
66
+ end
67
+
54
68
  where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
55
- "#{PREFIX} BROKEN — #{number(count)} break#{'s' unless count == 1} #{where}; #{uncovered}"
69
+ "#{PREFIX} BROKEN — #{breaks} #{where}; #{uncovered}; #{anchor_text}"
56
70
  end
57
71
 
72
+ def anchor_text = (@anchor_text ||= AnchorText.new(@v))
73
+
58
74
  def no_chain
59
75
  "#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
60
76
  "none chained; nothing was verified"
61
77
  end
62
78
 
63
- def no_key
64
- "#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
65
- end
79
+ def no_key = "#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
66
80
 
81
+ # The purge clause leads here too (FR-011), but the uncovered count stays as 0.6.0 wrote it.
67
82
  def unverifiable
68
- unknown = @v.breaks.select { |found| found.kind == :unknown_key }
69
- rows = unknown.count { |found| entry_position?(found.sequence) }
70
- checked = @v.covered ? @v.covered.size - rows : 0
71
- "#{PREFIX} UNVERIFIABLE — #{number(rows)} entries signed by #{missing_keys(unknown)} " \
72
- "not configured; the other #{number(checked)} in #{range(@v.covered)} show no " \
73
- "breaks, which is not a pass; #{number(@v.uncovered_now)} earlier entries not covered"
74
- end
75
-
76
- # The genesis is position 0 and is not an entry.
77
- def entry_position?(sequence)
78
- sequence.is_a?(Integer) && sequence.positive?
79
- end
80
-
81
- def missing_keys(unknown)
82
- keys = unknown.map(&:key_id).compact.uniq
83
- keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
83
+ "#{PREFIX} UNVERIFIABLE — #{UnverifiableText.new(@v)}; #{RetentionText.new(@v).clause}" \
84
+ "#{number(@v.uncovered_now)} earlier entries not covered; #{anchor_text}"
84
85
  end
85
86
 
87
+ # After a purge, what it removed comes first (specs/005 FR-011): no line says what was
88
+ # verified without saying what was purged before it.
86
89
  def uncovered
87
- parts = ["#{number(@v.uncovered_now)} earlier entries not covered"]
90
+ retention = RetentionText.new(@v)
91
+ parts = [retention.clause, "#{number(@v.uncovered_now)} earlier entries not covered"]
88
92
  at_genesis = @v.uncovered_at_genesis
89
- parts << " (#{number(at_genesis)} when the chain started)" if at_genesis
93
+ if at_genesis
94
+ parts << " (#{number(at_genesis)} when the chain started#{retention.pre_chain})"
95
+ end
90
96
  unless @v.unchained_at_genesis.to_i.zero?
91
97
  parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
92
98
  "chain started, not covered"
@@ -94,9 +100,7 @@ module Reeve
94
100
  parts.join
95
101
  end
96
102
 
97
- def totals
98
- " totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
99
- end
103
+ def totals = " totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
100
104
 
101
105
  def more(listed, limit)
102
106
  remaining = listed.size - limit
@@ -105,14 +109,24 @@ module Reeve
105
109
  " … and #{number(remaining)} more (the totals above are complete)"
106
110
  end
107
111
 
112
+ # One space at least between columns, so a label or kind longer than its column
113
+ # (`checkpoint #48,000`, `checkpoint_not_reached`) never runs into the next.
108
114
  def line_for(found)
109
- kind = found.kind.to_s.ljust(KIND_WIDTH)
110
- " #{label(found).ljust(LABEL_WIDTH)}#{kind}#{found.detail}".rstrip
115
+ label = label(found).ljust(LABEL_WIDTH - 1)
116
+ kind = found.kind.to_s.ljust(KIND_WIDTH - 1)
117
+ " #{label} #{kind} #{found.detail}".rstrip
111
118
  end
112
119
 
113
120
  def label(found)
114
- sequence = found.sequence
115
121
  return "invocation #{found.invocation_id}" if found.invocation_id
122
+ return "checkpoint ##{number(found.checkpoint)}" if found.checkpoint
123
+ return "retention #{found.retention}" if found.retention
124
+ return "epoch #{found.epoch}" if found.sequence.nil? && found.epoch
125
+
126
+ position(found.sequence)
127
+ end
128
+
129
+ def position(sequence)
116
130
  return "—" if sequence.nil?
117
131
  return "##{range(sequence)}" if sequence.is_a?(Range)
118
132
 
@@ -129,6 +143,192 @@ module Reeve
129
143
  self.class.number(value.to_i)
130
144
  end
131
145
  end
146
+
147
+ # What an unverifiable run could not check, and what it did (the UNVERIFIABLE line). Rows
148
+ # signed by a key that is not configured are counted; a checkpoint whose key is missing is
149
+ # named apart from them and is not one (specs/004).
150
+ class UnverifiableText
151
+ def initialize(verification)
152
+ @v = verification
153
+ unknown = verification.breaks.select { |found| found.kind == :unknown_key }
154
+ @checkpoints, unknown = unknown.partition(&:checkpoint)
155
+ @records, @signed = unknown.partition(&:retention)
156
+ end
157
+
158
+ def to_s
159
+ rows = @signed.count { |found| entry?(found.sequence) }
160
+ checked = @v.covered ? @v.covered.size - rows : 0
161
+ "#{unchecked(rows)}; the other #{Report.number(checked)} in #{range} show no breaks, " \
162
+ "which is not a pass"
163
+ end
164
+
165
+ private
166
+
167
+ def unchecked(rows)
168
+ parts = []
169
+ parts << "#{Report.number(rows)} entries signed by #{missing(@signed)}" if @signed.any?
170
+ parts << other("checkpoint", @checkpoints) if @checkpoints.any?
171
+ parts << other("retention record", @records) if @records.any?
172
+ parts.join("; ")
173
+ end
174
+
175
+ # Not entries, so named apart and not counted as rows.
176
+ def other(noun, found)
177
+ count = found.size
178
+ "#{Report.number(count)} #{noun}#{'s' unless count == 1} signed by #{missing(found)}"
179
+ end
180
+
181
+ # The genesis is position 0 and is not an entry.
182
+ def entry?(sequence)
183
+ sequence.is_a?(Integer) && sequence.positive?
184
+ end
185
+
186
+ def missing(unknown)
187
+ keys = unknown.map(&:key_id).compact.uniq
188
+ named = keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
189
+ "#{named} not configured"
190
+ end
191
+
192
+ def range
193
+ return "—" if @v.covered.nil?
194
+
195
+ "#{Report.number(@v.covered.first)}–#{Report.number(@v.covered.last)}"
196
+ end
197
+ end
198
+
199
+ # What the ledger's purges removed, in words (specs/005 FR-011): the clause that leads the
200
+ # coverage on every walked summary line, and the pre-chain rows a purge removed.
201
+ class RetentionText
202
+ def initialize(verification)
203
+ @retention = verification.retention
204
+ @awaiting = verification.awaiting_purge.to_i
205
+ end
206
+
207
+ def clause
208
+ boundary = @retention&.boundary
209
+ return "" if boundary.nil?
210
+
211
+ text = "1–#{Report.number(boundary.through_sequence)} purged (retention record " \
212
+ "#{boundary.purge_number}, #{boundary.purged_at.utc.strftime('%Y-%m-%d')}); "
213
+ text += "#{Report.number(@awaiting)} entries awaiting purge; " if @awaiting.positive?
214
+ text
215
+ end
216
+
217
+ def pre_chain
218
+ purged = if @retention
219
+ @retention.summaries.reject { |found| found.status == :altered }
220
+ .sum(&:removed_pre_chain)
221
+ else
222
+ 0
223
+ end
224
+ purged.positive? ? ", #{Report.number(purged)} purged" : ""
225
+ end
226
+ end
227
+
228
+ # What a run was anchored by, in words (specs/004 FR-014, FR-015): the clause every walked
229
+ # summary line ends with, and the wording for a walk from a checkpoint and for an emptied
230
+ # ledger that checkpoints saw.
231
+ class AnchorText
232
+ # A checkpoint's time for people: "2026-10-25 14:00 UTC". A time that does not parse is
233
+ # shown as written; verification never depends on it.
234
+ def self.made_on(made_at)
235
+ Time.iso8601(made_at).utc.strftime("%Y-%m-%d %H:%M UTC")
236
+ rescue ArgumentError
237
+ made_at
238
+ end
239
+
240
+ def initialize(verification)
241
+ @v = verification
242
+ end
243
+
244
+ # The highest checkpoint that checked, and how many entries it does not cover.
245
+ def to_s
246
+ anchor = @v.anchored_at
247
+ return "not anchored" if anchor.nil?
248
+
249
+ after = @v.beyond_anchor.to_i
250
+ "anchored at ##{number(anchor.head_sequence)} (checkpoint #{made_on(anchor)}), " \
251
+ "#{number(after)} #{after == 1 ? 'entry' : 'entries'} after it"
252
+ end
253
+
254
+ def walked_from
255
+ start = @v.walked_from
256
+ from = "checkpoint ##{number(start.head_sequence)} (#{made_on(start)})"
257
+ walked = if @v.covered
258
+ "sequences #{range} verified from #{from}"
259
+ else
260
+ "no entries after #{from}"
261
+ end
262
+ "#{walked}; #{number(start.head_sequence)} earlier chained entries not walked by " \
263
+ "this run"
264
+ end
265
+
266
+ # No walk, and checkpoints that saw a chain.
267
+ def emptied?
268
+ @v.head_at_start.nil? && @v.checkpoints.any?
269
+ end
270
+
271
+ def emptied
272
+ count = @v.checkpoints.size
273
+ "the ledger has no chain, but #{number(count)} checkpoint#{'s' unless count == 1} " \
274
+ "name#{'s' if count == 1} one"
275
+ end
276
+
277
+ private
278
+
279
+ def number(value)
280
+ Report.number(value)
281
+ end
282
+
283
+ def made_on(checkpoint)
284
+ AnchorText.made_on(checkpoint.made_at)
285
+ end
286
+
287
+ def range
288
+ "#{number(@v.covered.first)}–#{number(@v.covered.last)}"
289
+ end
290
+ end
291
+
292
+ # The key epochs, one line each (specs/003 FR-013), shown once they say something the
293
+ # summary does not: more than one, one that is not verified, or rows chained before the
294
+ # first. A ledger with one verified epoch and nothing before it reads as 0.6.0's did.
295
+ class EpochLines
296
+ STATUS_WORDS = {
297
+ verified: "verified", key_not_configured: "key not configured", altered: "altered"
298
+ }.freeze
299
+
300
+ def initialize(verification)
301
+ @epochs = verification.epochs
302
+ @before = verification.before_first_epoch.to_i
303
+ end
304
+
305
+ def to_a
306
+ return [] unless shown?
307
+
308
+ lines = @epochs.map { |epoch| line(epoch) }
309
+ return lines unless @before.positive?
310
+
311
+ [" before epoch 0 #{entries(@before)} chained before key epochs began", *lines]
312
+ end
313
+
314
+ private
315
+
316
+ def shown?
317
+ return false if @epochs.empty?
318
+
319
+ @epochs.size > 1 || @epochs.first.status != :verified || @before.positive?
320
+ end
321
+
322
+ def line(epoch)
323
+ " epoch #{epoch.epoch} key #{epoch.key_id} " \
324
+ "from sequence #{Report.number(epoch.first_sequence)} " \
325
+ "#{entries(epoch.entry_count)} #{STATUS_WORDS.fetch(epoch.status)}"
326
+ end
327
+
328
+ def entries(count)
329
+ "#{Report.number(count)} #{count == 1 ? 'entry' : 'entries'}"
330
+ end
331
+ end
132
332
  end
133
333
  end
134
334
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Reeve
8
+ module Audit
9
+ class Chain
10
+ module Retention
11
+ # The archive a purge writes before it removes anything (specs/005 FR-007,
12
+ # contracts/archive-v1.md): one JSON line of header, then every purged row with every
13
+ # column, chain columns included, so the range can be verified on its own.
14
+ #
15
+ # Written to `path.tmp`, flushed and renamed, so a partial archive is never at `path`.
16
+ module Archive
17
+ FORMAT = "reeve-ledger-archive-v1"
18
+ BATCH = 1_000
19
+
20
+ module_function
21
+
22
+ # Writes the archive for `plan` and returns its SHA-256.
23
+ def write(path, plan, entry_class:)
24
+ partial = "#{path}.tmp"
25
+ File.open(partial, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
26
+ file.puts(JSON.generate(header(plan)))
27
+ each_row(plan, entry_class) { |row| file.puts(JSON.generate(line(row))) }
28
+ file.flush
29
+ file.fsync
30
+ end
31
+ File.rename(partial, path)
32
+ ::Digest::SHA256.file(path).hexdigest
33
+ rescue StandardError
34
+ FileUtils.rm_f(partial)
35
+ raise
36
+ end
37
+
38
+ def header(plan)
39
+ {
40
+ "format" => FORMAT, "ledger" => ChainRecord::LEDGER, "purge" => plan.purge,
41
+ "from_sequence" => plan.from, "through_sequence" => plan.through,
42
+ "through_checkpoint" => plan.checkpoint.to_line, "start" => plan.start || genesis,
43
+ "chained" => plan.removed_chained, "pre_chain" => plan.removed_pre_chain,
44
+ "reeve_version" => Reeve::VERSION
45
+ }.sort.to_h
46
+ end
47
+
48
+ # Purge 0 starts at the genesis, so the archive carries it.
49
+ def genesis
50
+ record = ChainRecord.ledger
51
+ fields = Canonical::GENESIS_FIELDS + ["genesis_digest"]
52
+ { "genesis" => fields.to_h { |field| [field, value(record[field])] } }
53
+ end
54
+
55
+ # Pre-chain rows first, then the chained range in sequence order, a batch at a time.
56
+ def each_row(plan, entry_class, &block)
57
+ entry_class.where("contract_version < 3").order(:id).find_each(batch_size: BATCH,
58
+ &block)
59
+ after = plan.from - 1
60
+ loop do
61
+ batch = entry_class.where(chain_sequence: (after + 1)..plan.through)
62
+ .order(:chain_sequence).limit(BATCH).to_a
63
+ batch.each(&block)
64
+ break if batch.size < BATCH
65
+
66
+ after = batch.last.chain_sequence
67
+ end
68
+ end
69
+
70
+ # The header of the archive at `path`, refusing one in another format: a verifier
71
+ # never guesses at a format it does not know.
72
+ def read_header(path)
73
+ header = JSON.parse(File.foreach(path).first.to_s)
74
+ return header if header.is_a?(Hash) && header["format"] == FORMAT
75
+
76
+ found = header.is_a?(Hash) ? header["format"].inspect : "unreadable"
77
+ raise ArgumentError, "#{path} is not a #{FORMAT} archive (format #{found})"
78
+ rescue JSON::ParserError
79
+ raise ArgumentError, "#{path} is not a #{FORMAT} archive (its first line is not JSON)"
80
+ end
81
+
82
+ # The header's genesis, with its time as a Time again, for Canonical.genesis_digest.
83
+ def genesis_values(genesis)
84
+ genesis.merge("genesis_at" => Time.iso8601(genesis["genesis_at"]))
85
+ end
86
+
87
+ def line(row)
88
+ row.attributes.except("id").transform_values { |item| value(item) }.sort.to_h
89
+ end
90
+
91
+ def value(item)
92
+ item.respond_to?(:utc) ? item.utc.iso8601(6) : item
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end