active_sanction 1.0.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 (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,103 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "fileutils"
7
+ require "json"
8
+
9
+ module ActiveSanction
10
+ class ValidatorStore
11
+ # Validators in one small JSON file, so a sync that runs from cron gets a
12
+ # 304 on its second run rather than on its second run within one process.
13
+ #
14
+ # ActiveSanction::ValidatorStore::FileSystem.new
15
+ # # => ~/.cache/active_sanction/validators.json
16
+ #
17
+ # JSON, indented, keyed by the caller's key: this file is the first thing
18
+ # somebody opens when a sync is downloading more than it should, and it is
19
+ # a few hundred bytes even with every source in it. It is also the file the
20
+ # acceptance criterion is about -- deleting it forces a full re-download,
21
+ # and nothing else is lost with it, because validators are an optimization
22
+ # and never the record of what a list contained.
23
+ #
24
+ # Entries are re-read on every access rather than memoized. The file is
25
+ # tiny, and a cache that has to be reloaded to see another process's write
26
+ # is a cache that lies to a sync running beside a CLI command.
27
+ #
28
+ # Writes go to a temporary sibling and are renamed into place, so an
29
+ # interrupted write leaves the previous file rather than a truncated one.
30
+ # Two processes writing at once still resolve last-writer-wins: the cost is
31
+ # one avoidable download, which is the right trade for not putting a lock
32
+ # file in a user's cache directory.
33
+ class FileSystem < ValidatorStore
34
+ extend T::Sig
35
+
36
+ # @api private
37
+ DEFAULT_FILENAME = T.let("validators.json", String)
38
+
39
+ sig { returns(String) }
40
+ attr_reader :path
41
+
42
+ sig { params(path: T.untyped).void }
43
+ def initialize(path: nil)
44
+ @path = T.let(
45
+ ::File.expand_path((path || ::File.join(ActiveSanction.config.cache_dir, DEFAULT_FILENAME)).to_s), String
46
+ )
47
+ super()
48
+ end
49
+
50
+ sig { override.returns(String) }
51
+ def inspect = "#<#{self.class} #{path} #{size} entr#{size == 1 ? "y" : "ies"}>"
52
+
53
+ private
54
+
55
+ sig { override.returns(T::Hash[String, Validators]) }
56
+ def entries
57
+ raw = read
58
+ raw.to_h { |key, attributes| [key, Validators.from_h(attributes)] }
59
+ rescue ArgumentError, TypeError => e
60
+ raise CorruptStore, "#{path} does not hold validators (#{e.message}). Delete it to re-download in full."
61
+ end
62
+
63
+ sig do
64
+ override.params(block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped))
65
+ .returns(T.untyped)
66
+ end
67
+ def commit(&block)
68
+ all = entries
69
+ result = block.call(all)
70
+ write(all)
71
+ result
72
+ end
73
+
74
+ # A missing file is an empty store, not an error: it is what a first run
75
+ # sees, and what deleting the file leaves behind.
76
+ sig { returns(T::Hash[String, T.untyped]) }
77
+ def read
78
+ return {} unless ::File.exist?(path)
79
+
80
+ contents = ::File.read(path, encoding: Encoding::UTF_8)
81
+ return {} if contents.strip.empty?
82
+
83
+ parsed = JSON.parse(contents)
84
+ raise CorruptStore, "#{path} is not a JSON object of validators. Delete it to re-download in full." unless
85
+ parsed.is_a?(Hash)
86
+
87
+ parsed
88
+ rescue JSON::ParserError => e
89
+ raise CorruptStore, "#{path} is not valid JSON (#{e.message}). Delete it to re-download in full."
90
+ end
91
+
92
+ sig { params(all: T::Hash[String, Validators]).void }
93
+ def write(all)
94
+ FileUtils.mkdir_p(::File.dirname(path))
95
+ temporary = T.let("#{path}.#{Process.pid}.tmp", T.nilable(String))
96
+ ::File.write(T.must(temporary), "#{JSON.pretty_generate(all.transform_values(&:to_h))}\n")
97
+ ::File.rename(T.must(temporary), path)
98
+ ensure
99
+ FileUtils.rm_f(temporary) if temporary
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,41 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ class ValidatorStore
8
+ # Validators for the life of one process, and no longer.
9
+ #
10
+ # The default for a caller that builds its own client and does not want a
11
+ # file appearing under a home directory, and what the suite uses so an
12
+ # example never depends on -- or leaves behind -- state on disk. A
13
+ # long-running process that syncs on a schedule still gets the whole
14
+ # benefit of conditional GET from it; only a fresh boot pays for a full
15
+ # download.
16
+ class Memory < ValidatorStore
17
+ extend T::Sig
18
+
19
+ sig { params(entries: T::Hash[T.untyped, T.nilable(Validators)]).void }
20
+ def initialize(entries = {})
21
+ @entries = T.let({}, T::Hash[String, Validators])
22
+ @mutex = T.let(Mutex.new, Mutex)
23
+ entries.each { |key, validators| self[key] = validators }
24
+ super()
25
+ end
26
+
27
+ private
28
+
29
+ sig { override.returns(T::Hash[String, Validators]) }
30
+ attr_reader :entries
31
+
32
+ sig do
33
+ override.params(block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped))
34
+ .returns(T.untyped)
35
+ end
36
+ def commit(&block)
37
+ @mutex.synchronize { block.call(@entries) }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,119 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/validators"
8
+
9
+ module ActiveSanction
10
+ # Where a process remembers what a publisher last said about a list, so the
11
+ # next fetch can ask "still this?" instead of "give me everything".
12
+ #
13
+ # store = ActiveSanction::ValidatorStore::FileSystem.new
14
+ # store[:ofac_sdn] #=> #<ActiveSanction::Validators ...> or nil
15
+ # store.delete(:ofac_sdn) # next fetch downloads in full
16
+ #
17
+ # Keys are the caller's: a URL by default, or a stable source name for an
18
+ # adapter (#12) whose publisher may move its file. Validators carry the URL
19
+ # they came from either way, so a moved file is detected rather than sent a
20
+ # meaningless `If-None-Match`.
21
+ #
22
+ # This is a base class rather than an interface document: subclasses supply
23
+ # the two lines that differ -- how the entries are read and how a change to
24
+ # them is committed -- and inherit the rest. #24 will do the same for
25
+ # snapshots, at a scale where the difference matters more.
26
+ class ValidatorStore
27
+ extend T::Sig
28
+
29
+ # A store whose backing bytes cannot be read as validators. Deliberately
30
+ # fatal rather than treated as an empty store: silently re-downloading tens
31
+ # of megabytes on every sync is the kind of failure that hides for months,
32
+ # and the fix -- delete the file -- is in the message.
33
+ class CorruptStore < StorageError; end
34
+
35
+ # Reads validators, or nil when nothing is stored under the key. Nil is the
36
+ # answer to "have we ever fetched this?", so it is not conflated with a
37
+ # record that exists but carries no validators.
38
+ sig { params(key: T.untyped).returns(T.nilable(Validators)) }
39
+ def [](key)
40
+ entries[key!(key)]
41
+ end
42
+
43
+ # Storing nil, or validators the publisher gave nothing to be conditional
44
+ # with, is a delete: a record that cannot save a download is not worth
45
+ # keeping, and leaving one behind makes #stale? lie about having a usable
46
+ # copy.
47
+ sig { params(key: T.untyped, validators: T.nilable(Validators)).returns(T.nilable(Validators)) }
48
+ def []=(key, validators)
49
+ normalized = key!(key)
50
+ commit do |all|
51
+ if validators.nil? || validators.empty?
52
+ all.delete(normalized)
53
+ else
54
+ all[normalized] = validators
55
+ end
56
+ end
57
+ validators
58
+ end
59
+ alias store []=
60
+
61
+ sig { params(key: T.untyped).returns(T.untyped) }
62
+ def delete(key)
63
+ normalized = key!(key)
64
+ commit { |all| all.delete(normalized) }
65
+ end
66
+
67
+ sig { params(key: T.untyped).returns(T::Boolean) }
68
+ def key?(key) = entries.key?(key!(key))
69
+
70
+ sig { returns(T::Array[String]) }
71
+ def keys = entries.keys
72
+
73
+ sig { returns(Integer) }
74
+ def size = entries.size
75
+
76
+ sig { returns(T::Boolean) }
77
+ def empty? = entries.empty?
78
+
79
+ sig { returns(T.self_type) }
80
+ def clear
81
+ commit(&:clear)
82
+ self
83
+ end
84
+
85
+ sig { returns(T::Hash[String, T.untyped]) }
86
+ def to_h = entries.transform_values(&:to_h)
87
+
88
+ sig { returns(String) }
89
+ def inspect = "#<#{self.class} #{size} entr#{size == 1 ? "y" : "ies"}>"
90
+
91
+ private
92
+
93
+ # Keys reach here as symbols from source adapters and as URL strings from
94
+ # ad-hoc callers; both have to survive a round-trip through JSON, which has
95
+ # only strings.
96
+ sig { params(key: T.untyped).returns(String) }
97
+ def key!(key)
98
+ string = key.to_s.strip
99
+ raise InvalidArgument, "a validator key is required" if string.empty?
100
+
101
+ -string
102
+ end
103
+
104
+ # A hash of key => Validators. Subclasses may rebuild it per call.
105
+ sig { returns(T::Hash[String, Validators]) }
106
+ def entries
107
+ raise UnsupportedError, "#{self.class} must implement #entries"
108
+ end
109
+
110
+ # Yields the current entries for mutation and persists the result.
111
+ sig { params(_block: T.proc.params(all: T::Hash[String, Validators]).returns(T.untyped)).returns(T.untyped) }
112
+ def commit(&_block)
113
+ raise UnsupportedError, "#{self.class} must implement #commit"
114
+ end
115
+ end
116
+ end
117
+
118
+ require "active_sanction/validator_store/memory"
119
+ require "active_sanction/validator_store/file_system"
@@ -0,0 +1,195 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+ require "active_sanction/error"
8
+
9
+ module ActiveSanction
10
+ # The cache validators a publisher handed back with a list, plus enough
11
+ # context to know whether they still apply.
12
+ #
13
+ # validators.request_headers
14
+ # # => { "If-None-Match" => "\"0953154d0fb5aff918c5ec1daf6e9c0e\"",
15
+ # # "If-Modified-Since" => "Fri, 28 Aug 2026 14:02:55 GMT" }
16
+ #
17
+ # Every launch source serves both, verified live -- OFAC's SDN.CSV, the UN's
18
+ # consolidated.xml and Canada's sema-lmes.xml -- so a routine sync of lists
19
+ # that change daily at most costs three 304s instead of tens of megabytes.
20
+ #
21
+ # Both values are stored and echoed back verbatim. An `ETag` is an opaque
22
+ # string that only its origin server can interpret, and `Last-Modified` is an
23
+ # HTTP-date whose formatting is the server's business; parsing either one to
24
+ # re-render it is how a working conditional GET turns into a full download
25
+ # nobody notices.
26
+ #
27
+ # `url` is the URL that was *requested*, not the one that finally answered:
28
+ # OFAC redirects to blob storage, and the hop it lands on is not stable
29
+ # enough to key a cache by. It is kept so validators can be discarded when a
30
+ # publisher moves its file -- sending a previous file's ETag to a new URL
31
+ # invites a 304 that means nothing.
32
+ #
33
+ # Instances are frozen on construction and compare by value.
34
+ class Validators
35
+ extend T::Sig
36
+
37
+ # @api private
38
+ MEMBERS = T.let(%i[url etag last_modified checked_at updated_at].freeze, T::Array[Symbol])
39
+
40
+ # The URL that was requested, not the one that finally answered -- see the
41
+ # class comment.
42
+ sig { returns(String) }
43
+ attr_reader :url
44
+
45
+ # Opaque, and echoed back verbatim: only the origin server can interpret
46
+ # either of them.
47
+ sig { returns(T.nilable(String)) }
48
+ attr_reader :etag
49
+
50
+ sig { returns(T.nilable(String)) }
51
+ attr_reader :last_modified
52
+
53
+ sig { returns(Time) }
54
+ attr_reader :checked_at
55
+
56
+ sig { returns(Time) }
57
+ attr_reader :updated_at
58
+
59
+ # `url` is the caller's URL rather than `response.uri` for the reason in
60
+ # the class comment: the response may have come from a redirect target.
61
+ sig { params(response: T.untyped, url: T.untyped, at: T.untyped).returns(T.attached_class) }
62
+ def self.from_response(response, url:, at: nil)
63
+ new(url: url, etag: response.etag, last_modified: response.last_modified, checked_at: at)
64
+ end
65
+
66
+ # Rebuilds from #to_h output, accepting string keys so validators survive
67
+ # the round-trip through the JSON the store writes.
68
+ sig { params(hash: T.untyped).returns(T.attached_class) }
69
+ def self.from_h(hash)
70
+ attributes = hash.to_h.transform_keys(&:to_sym)
71
+ unknown = attributes.keys - MEMBERS
72
+ raise InvalidArgument, "unknown Validators attribute(s): #{unknown.join(", ")}" if unknown.any?
73
+
74
+ # `new(**hash)` past a required keyword parameter is one of the few
75
+ # things Sorbet cannot check statically. #initialize validates what
76
+ # arrives, which is where a bad round-trip is caught.
77
+ T.unsafe(self).new(**attributes)
78
+ end
79
+
80
+ # `checked_at` is when the publisher last confirmed this copy, whether by
81
+ # 200 or by 304; `updated_at` is when the bytes themselves last changed.
82
+ # Staleness follows the first, because "when did we last ask?" is the
83
+ # question a sync schedule answers; the second is what a human wants when
84
+ # reading why a list looks old.
85
+ sig do
86
+ params(url: T.untyped, etag: T.untyped, last_modified: T.untyped, checked_at: T.untyped,
87
+ updated_at: T.untyped).void
88
+ end
89
+ def initialize(url:, etag: nil, last_modified: nil, checked_at: nil, updated_at: nil)
90
+ @url = T.let(url!(url), String)
91
+ @etag = T.let(string_or_nil(etag), T.nilable(String))
92
+ @last_modified = T.let(string_or_nil(last_modified), T.nilable(String))
93
+ @checked_at = T.let(time!(checked_at), Time)
94
+ @updated_at = T.let(updated_at.nil? ? @checked_at : time!(updated_at), Time)
95
+ freeze
96
+ end
97
+
98
+ # True when the publisher gave us nothing to be conditional with. Such a
99
+ # record is still worth storing -- it says we looked -- but it cannot save
100
+ # a download, so callers treat it as no validators at all.
101
+ sig { returns(T::Boolean) }
102
+ def empty? = etag.nil? && last_modified.nil?
103
+
104
+ sig { returns(T::Boolean) }
105
+ def present? = !empty?
106
+
107
+ # Both are sent when both are known. RFC 9110 has the server prefer
108
+ # `If-None-Match` and ignore the date, but a cache in front of it may only
109
+ # honour one, and the second header costs 40 bytes on a request that is
110
+ # trying to avoid 126 MB.
111
+ sig { returns(T::Hash[String, String]) }
112
+ def request_headers
113
+ headers = {}
114
+ headers["If-None-Match"] = etag if etag
115
+ headers["If-Modified-Since"] = last_modified if last_modified
116
+ headers.freeze
117
+ end
118
+
119
+ # Validators belong to the URL they came from. A publisher that moves its
120
+ # file gets a full download rather than a 304 from whatever is now at the
121
+ # old address.
122
+ sig { params(other: T.untyped).returns(T::Boolean) }
123
+ def for?(other) = url == other.to_s
124
+
125
+ # What to store after the publisher answered 304. The copy is unchanged,
126
+ # so `updated_at` stands; only the moment we confirmed it moves. A 304 is
127
+ # allowed to carry a fresh ETag, and when it does that value is the one to
128
+ # send next time.
129
+ sig { params(response: T.untyped, at: T.untyped).returns(Validators) }
130
+ def confirmed_by(response, at: nil)
131
+ self.class.new(url: url, etag: response.etag || etag,
132
+ last_modified: response.last_modified || last_modified,
133
+ checked_at: at, updated_at: updated_at)
134
+ end
135
+
136
+ sig { returns(T::Hash[Symbol, T.untyped]) }
137
+ def to_h
138
+ {
139
+ url: url,
140
+ etag: etag,
141
+ last_modified: last_modified,
142
+ checked_at: checked_at.iso8601,
143
+ updated_at: updated_at.iso8601
144
+ }
145
+ end
146
+
147
+ sig { params(other: T.untyped).returns(T::Boolean) }
148
+ def ==(other)
149
+ return false unless other.instance_of?(self.class)
150
+
151
+ to_h == other.to_h
152
+ end
153
+ alias eql? ==
154
+
155
+ sig { returns(Integer) }
156
+ def hash = [self.class, to_h].hash
157
+
158
+ sig { returns(String) }
159
+ def inspect
160
+ "#<#{self.class} #{url} etag=#{etag.inspect} last_modified=#{last_modified.inspect} " \
161
+ "checked_at=#{checked_at.iso8601}>"
162
+ end
163
+
164
+ private
165
+
166
+ sig { params(value: T.untyped).returns(String) }
167
+ def url!(value)
168
+ string = value.to_s.strip
169
+ raise InvalidArgument, "url is required" if string.empty?
170
+
171
+ -string
172
+ end
173
+
174
+ # Truncated to the second, which is the precision #to_h serializes, so a
175
+ # stored record reloads to a value equal to the one that was written.
176
+ sig { params(value: T.untyped).returns(Time) }
177
+ def time!(value)
178
+ time = case value
179
+ when nil then Time.now
180
+ when Time then value
181
+ when String then Time.parse(value)
182
+ else raise InvalidArgument, "not a time: #{value.inspect}"
183
+ end
184
+ Time.at(time.to_i).utc
185
+ end
186
+
187
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
188
+ def string_or_nil(value)
189
+ return nil if value.nil?
190
+
191
+ string = value.to_s.strip
192
+ string.empty? ? nil : -string
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveSanction
5
+ # The gem version, and what CHANGELOG.md is about. Moves under SemVer for a
6
+ # new source adapter, a storage fix or a documentation release -- none of
7
+ # which change what a name scores. MATCHER_VERSION, below, is the one that
8
+ # answers that question.
9
+ VERSION = "1.0.0"
10
+
11
+ # Which matching pipeline scored a decision, stamped onto every MatchResult
12
+ # and bumped whenever a change to the normalizer, the index, the similarity
13
+ # algorithms or the scorer could move a score.
14
+ #
15
+ # Deliberately not VERSION. The gem version moves for a new source adapter,
16
+ # a storage fix, a documentation release -- none of which change what a name
17
+ # scores -- and an auditor asking "would this screening come out the same
18
+ # today?" needs the answer to that question rather than a release number
19
+ # that also answers several others. Its companions on the record are the
20
+ # weights and the snapshot checksum; between the three, a past decision is
21
+ # re-derivable.
22
+ MATCHER_VERSION = "1"
23
+ end