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,277 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_record"
7
+ require "json"
8
+ require "active_sanction/configuration"
9
+ require "active_sanction/snapshot"
10
+ require "active_sanction/storage"
11
+ require "active_sanction/storage/base"
12
+ require "active_sanction/storage/meta"
13
+ require "active_sanction/storage/active_record/row"
14
+ require "active_sanction/storage/active_record/reader"
15
+ require "active_sanction/storage/active_record/writer"
16
+
17
+ module ActiveSanction
18
+ module Storage
19
+ # Snapshots in the host application's database.
20
+ #
21
+ # $ rails generate active_sanction:install && rails db:migrate
22
+ #
23
+ # store = ActiveSanction::Storage::ActiveRecord.new
24
+ # store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
25
+ # store.snapshot_meta(:ofac_sdn).age # one row read
26
+ #
27
+ # ### Optional means optional
28
+ #
29
+ # ActiveRecord is not a dependency of this gem and must not become one.
30
+ # Nothing requires this file unless a host has already loaded ActiveRecord
31
+ # itself -- see the guard at the bottom of storage.rb -- and the gem is
32
+ # fully usable, including Storage::FileSystem, with ActiveRecord absent.
33
+ # The default adapter costs a directory; this one costs a migration, and it
34
+ # is for an installation that already has a database and wants to *query*
35
+ # its lists, not a prerequisite for using this library.
36
+ #
37
+ # ### What the database buys, and what it does not
38
+ #
39
+ # It buys the prefilter. Scoring 19,015 OFAC records against one name in
40
+ # Ruby is the cost the matcher (#33) wants to avoid paying, and an equality
41
+ # probe on an indexed column narrows that to a few hundred candidates
42
+ # before any of them are loaded:
43
+ #
44
+ # Row::Name.matching("Aiman al-Zawahiri").pluck(:entity_id)
45
+ # Row::Identifier.matching("AB-123 456").pluck(:entity_id)
46
+ #
47
+ # It does not buy a different reading model. `each_entity` is inherited
48
+ # from Storage::Base rather than reimplemented as a cursor, and that is a
49
+ # decision rather than an omission: a snapshot's checksum is computed over
50
+ # the whole list, so a store that streamed rows straight to the matcher
51
+ # would be handing it records it cannot prove are all of them. Reading a
52
+ # list here materializes it and verifies it, exactly as the other adapters
53
+ # do. Tens of megabytes is a fine price for that; screening against a list
54
+ # that is quietly missing people is not.
55
+ #
56
+ # ### Writing
57
+ #
58
+ # One transaction per list, and `insert_all` in batches inside it. A sync
59
+ # that dies partway through 19,015 entities rolls back to the list that was
60
+ # there before it -- there is no half-updated state to inspect, and none to
61
+ # screen against. Row-at-a-time saves would be the obvious alternative and
62
+ # are not: 19,015 entities plus some 65,000 rows hanging off them is not
63
+ # work to do one `INSERT` at a time.
64
+ #
65
+ # ### Reading
66
+ #
67
+ # Nothing partial is ever returned. The Snapshot is rebuilt with the
68
+ # checksum stored beside it, so construction re-derives the digest over the
69
+ # records that actually came back and raises CorruptSnapshot when they do
70
+ # not agree -- a row deleted by hand, a write that half landed, a column
71
+ # edited in a console. A schema_version this gem does not know raises
72
+ # UnsupportedSchema before a record is read.
73
+ #
74
+ # ### Concurrency
75
+ #
76
+ # The database's problem, which is the point. A write is one transaction,
77
+ # so a reader sees the list as it was before it or as it is after it, and
78
+ # readers on other processes and other machines get that for free rather
79
+ # than from a rename that only holds within one filesystem.
80
+ class ActiveRecord < Base
81
+ extend T::Sig
82
+
83
+ # Snapshot versions this code can read. Anything above the version it
84
+ # writes was produced by a newer gem.
85
+ #
86
+ # @api private
87
+ READABLE_SCHEMA_VERSIONS = T.let(1..Snapshot::SCHEMA_VERSION, T::Range[Integer])
88
+
89
+ # Rows per `insert_all`. Big enough that a full OFAC sync is a few dozen
90
+ # statements rather than 19,015, small enough that no single statement is
91
+ # megabytes of SQL a database has to parse in one piece.
92
+ #
93
+ # @api private
94
+ DEFAULT_BATCH_SIZE = T.let(1_000, Integer)
95
+
96
+ # What `normalized_value` is declared as, because it is indexed and MySQL
97
+ # will not index an unbounded column. Comfortably past the longest name
98
+ # any of the launch lists publishes.
99
+ #
100
+ # @api private
101
+ PREFILTER_KEY_LIMIT = T.let(512, Integer)
102
+
103
+ # @api private
104
+ COMBINING_MARKS = T.let(/\p{Mn}+/, Regexp)
105
+ # @api private
106
+ NON_ALPHANUMERIC = T.let(/[^[:alnum:]]+/, Regexp)
107
+
108
+ # The key a name is filed under in `active_sanction_names.normalized_value`
109
+ # and the key a query has to build to find it:
110
+ #
111
+ # prefilter_key("Aiman al-ZAWAHIRI!") # => "aiman al zawahiri"
112
+ # prefilter_key("Ayman al-Ẓawāhirī") # => "ayman al zawahiri"
113
+ #
114
+ # Deliberately crude, and deliberately not the matcher's normalizer
115
+ # (#26). Its only job is candidate generation, where the cost of the two
116
+ # kinds of error is wildly asymmetric: a key that collides too eagerly
117
+ # costs a few extra records to score in Ruby, and a key that misses costs
118
+ # a sanctioned person who never reaches the scorer at all. So it folds
119
+ # width and diacritics, cases down, and reduces everything that is not
120
+ # alphanumeric to a single space -- and it stops there. It does not
121
+ # transliterate, drop legal forms (#27), or reorder tokens; those change
122
+ # what a name *means* and belong where a human can see the decision.
123
+ #
124
+ # Because it is stored, changing this fold makes the stored keys stale.
125
+ # A release that changes it will say so, and the fix is a re-sync.
126
+ sig { params(value: T.untyped).returns(String) }
127
+ def self.prefilter_key(value)
128
+ folded = value.to_s.unicode_normalize(:nfkd).gsub(COMBINING_MARKS, "").downcase
129
+ folded.gsub(NON_ALPHANUMERIC, " ").strip.squeeze(" ").slice(0, PREFILTER_KEY_LIMIT).to_s
130
+ end
131
+
132
+ # Whether the migration has been run. Not checked on construction: an
133
+ # adapter built in a Rails initializer must not open a connection to say
134
+ # hello, and a host running `rails db:migrate` would then be unable to
135
+ # boot the application that migrates it.
136
+ sig { returns(T::Boolean) }
137
+ def self.installed?
138
+ Row::ALL.all?(&:table_exists?)
139
+ rescue ::ActiveRecord::ActiveRecordError
140
+ false
141
+ end
142
+
143
+ # Rows per `insert_all` -- see DEFAULT_BATCH_SIZE.
144
+ sig { returns(Integer) }
145
+ attr_reader :batch_size
146
+
147
+ sig { params(batch_size: T.untyped).void }
148
+ def initialize(batch_size: DEFAULT_BATCH_SIZE)
149
+ @batch_size = T.let(batch_size!(batch_size), Integer)
150
+ super()
151
+ end
152
+
153
+ # Replaces the source's list inside one transaction: the previous
154
+ # generation is dropped and the new one written, or neither happens.
155
+ sig { override.params(snapshot: T.untyped).returns(Snapshot) }
156
+ def write_snapshot(snapshot)
157
+ stored = snapshot!(snapshot)
158
+ key = source_key!(stored.source)
159
+ connected do
160
+ Row::Base.transaction do
161
+ Row::Snapshot.find_by(source: key.to_s)&.discard!
162
+ Writer.new(create_row(key, stored), stored, batch_size: batch_size).call
163
+ end
164
+ end
165
+ stored
166
+ end
167
+
168
+ sig { override.params(source: T.untyped).returns(T.nilable(Snapshot)) }
169
+ def read_snapshot(source)
170
+ row = snapshot_row(source_key!(source))
171
+ return nil if row.nil?
172
+
173
+ schema_version!(row)
174
+ build(row)
175
+ end
176
+
177
+ # One row read, and no entities. What makes printing how old six lists
178
+ # are six primary-key lookups rather than six full deserializations.
179
+ sig { override.params(source: T.untyped).returns(T.nilable(Meta)) }
180
+ def snapshot_meta(source)
181
+ row = snapshot_row(source_key!(source))
182
+ return nil if row.nil?
183
+
184
+ Meta.new(source: row.source, fetched_at: row.fetched_at.to_time, checksum: row.checksum,
185
+ record_count: row.record_count, schema_version: row.schema_version,
186
+ source_version: row.source_version)
187
+ end
188
+
189
+ sig { override.params(source: T.untyped).returns(T::Boolean) }
190
+ def delete_snapshot(source)
191
+ key = source_key!(source)
192
+ connected do
193
+ Row::Base.transaction do
194
+ row = Row::Snapshot.find_by(source: key.to_s)
195
+ row&.discard!
196
+ !row.nil?
197
+ end
198
+ end
199
+ end
200
+
201
+ # Sorted in Ruby rather than by the database, so a summary does not
202
+ # reshuffle itself when the same lists are read through a connection with
203
+ # a different collation.
204
+ sig { override.returns(T::Array[Symbol]) }
205
+ def sources = connected { Row::Snapshot.pluck(:source) }.map(&:to_sym).sort
206
+
207
+ private
208
+
209
+ sig { params(key: Symbol).returns(T.untyped) }
210
+ def snapshot_row(key) = connected { Row::Snapshot.find_by(source: key.to_s) }
211
+
212
+ sig { params(key: Symbol, snapshot: Snapshot).returns(T.untyped) }
213
+ def create_row(key, snapshot)
214
+ Row::Snapshot.create!(source: key.to_s, fetched_at: snapshot.fetched_at, checksum: snapshot.checksum,
215
+ record_count: snapshot.record_count, schema_version: snapshot.schema_version,
216
+ source_version: snapshot.source_version)
217
+ end
218
+
219
+ # Snapshot recomputes the checksum over the records that came back and
220
+ # refuses to build if it does not match the one stored beside them, which
221
+ # is what turns every way of losing a row into an exception rather than
222
+ # into a clean report.
223
+ sig { params(row: T.untyped).returns(Snapshot) }
224
+ def build(row)
225
+ Reader.new(row).call
226
+ rescue Snapshot::ChecksumMismatch, ArgumentError, TypeError, JSON::ParserError => e
227
+ raise CorruptSnapshot, corrupt(row, e.message)
228
+ end
229
+
230
+ # Checked before a record is read, because a snapshot written by a newer
231
+ # gem will usually still deserialize -- into records missing whatever the
232
+ # new version added, with no symptom other than names that stop matching.
233
+ sig { params(row: T.untyped).void }
234
+ def schema_version!(row)
235
+ return if READABLE_SCHEMA_VERSIONS.cover?(row.schema_version)
236
+
237
+ raise UnsupportedSchema,
238
+ "the stored #{row.source} list was written under snapshot schema_version " \
239
+ "#{row.schema_version.inspect}; active_sanction #{VERSION} reads " \
240
+ "#{READABLE_SCHEMA_VERSIONS.first}-#{READABLE_SCHEMA_VERSIONS.last}. Upgrade the gem, or delete the " \
241
+ "snapshot and re-sync the source."
242
+ end
243
+
244
+ # An un-migrated database is a misconfigured installation, not a corrupt
245
+ # list, and it has a different fix -- so it is worth saying which one it
246
+ # is rather than letting a bare `no such table` reach the caller.
247
+ sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
248
+ def connected(&block)
249
+ block.call
250
+ rescue ::ActiveRecord::StatementInvalid
251
+ raise if self.class.installed?
252
+
253
+ raise ConfigurationError,
254
+ "the ActiveSanction storage tables are not in this database. Run " \
255
+ "`rails generate active_sanction:install && rails db:migrate`, or use " \
256
+ "ActiveSanction::Storage::FileSystem, which needs no schema."
257
+ end
258
+
259
+ sig { params(value: T.untyped).returns(Integer) }
260
+ def batch_size!(value)
261
+ # `exception: false` answers nil for anything unparseable, which the
262
+ # stdlib RBI does not say -- hence the nilable annotation.
263
+ integer = T.let(Integer(value, exception: false), T.nilable(Integer))
264
+ raise ConfigurationError, "batch_size must be a whole number of rows, got #{value.inspect}" if integer.nil?
265
+ raise ConfigurationError, "batch_size must be at least 1, got #{integer}" unless integer.positive?
266
+
267
+ integer
268
+ end
269
+
270
+ sig { params(row: T.untyped, detail: String).returns(String) }
271
+ def corrupt(row, detail)
272
+ "the stored #{row.source} list cannot be trusted to be the list it says it is (#{detail}). Nothing partial " \
273
+ "is returned from storage -- delete the snapshot and re-sync the source to replace it."
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,216 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/snapshot"
8
+ require "active_sanction/sources"
9
+ require "active_sanction/storage"
10
+ require "active_sanction/storage/meta"
11
+
12
+ module ActiveSanction
13
+ module Storage
14
+ # The persistence contract: one snapshot per source, written whole and read
15
+ # back whole.
16
+ #
17
+ # store = ActiveSanction::Storage::Memory.new
18
+ # store.write_snapshot(ActiveSanction::Sources[:ofac_sdn].new.sync)
19
+ #
20
+ # store.sources # => [:ofac_sdn]
21
+ # store.read_snapshot(:ofac_sdn) # => Snapshot, or nil
22
+ # store.snapshot_meta(:ofac_sdn) # => Meta: fetched_at, checksum, record_count
23
+ # store.each_entity { |entity| ... }
24
+ #
25
+ # ### The rule this class exists to enforce
26
+ #
27
+ # **Nothing on the query path may name a concrete store.** The matcher
28
+ # (#33) is written against these five methods and against nothing else,
29
+ # which is what lets an installation put its lists in gzipped JSON, in
30
+ # Postgres, or in a store it wrote itself without any of that reaching the
31
+ # code that decides whether two names are the same person. It is also why
32
+ # this interface lands before the matcher rather than after it: an
33
+ # interface extracted from a matcher that already reads files is an
34
+ # interface shaped like files.
35
+ #
36
+ # ### What an adapter implements
37
+ #
38
+ # Four methods, and they are deliberately coarse:
39
+ #
40
+ # write_snapshot(snapshot) # replace this source's list
41
+ # read_snapshot(source) # => Snapshot, or nil if never synced
42
+ # delete_snapshot(source) # => true if there was one
43
+ # sources # => [:ofac_sdn, ...], sorted
44
+ #
45
+ # A snapshot is the unit because it is the unit that carries a checksum. A
46
+ # store that wrote entities one at a time could leave a list half-replaced
47
+ # -- 8,000 of OFAC's 19,015 -- that still reads back as a valid list, and
48
+ # nothing downstream would be able to tell. Both planned adapters can honour
49
+ # that: #24 writes to a temporary file and renames it, #25 wraps the upsert
50
+ # in a transaction. Neither needs a finer-grained interface to do it.
51
+ #
52
+ # Everything else here is derived from those four and inherited, so an
53
+ # adapter that implements them gets the rest right by default. An adapter
54
+ # that can answer one of them better should override it -- `snapshot_meta`
55
+ # off a sidecar file rather than by reading the list, `each_entity` off a
56
+ # cursor rather than by materializing a snapshot -- and the shared
57
+ # conformance group ("a storage adapter") is what holds an override to
58
+ # meaning the same thing.
59
+ class Base
60
+ extend T::Sig
61
+
62
+ # Replaces everything stored for `snapshot.source` and returns the
63
+ # snapshot. One source at a time: a sync isolates its lists from each
64
+ # other (#34), so a write must not be able to disturb a list it was not
65
+ # given.
66
+ sig { params(_snapshot: T.untyped).returns(Snapshot) }
67
+ def write_snapshot(_snapshot)
68
+ raise UnsupportedError, "#{self.class} must implement #write_snapshot(snapshot)"
69
+ end
70
+
71
+ # The stored snapshot, or nil when the source has never been synced.
72
+ #
73
+ # Nil is the honest answer to "have we ever synced this?" and callers
74
+ # that cannot proceed without a list should ask through #fetch_snapshot,
75
+ # which raises. What an adapter must never do is answer an empty snapshot:
76
+ # "nothing was ever fetched" and "this list has nobody on it" are
77
+ # different states, and only one of them is safe to screen against.
78
+ sig { params(_source: T.untyped).returns(T.nilable(Snapshot)) }
79
+ def read_snapshot(_source)
80
+ raise UnsupportedError, "#{self.class} must implement #read_snapshot(source)"
81
+ end
82
+
83
+ # Drops a source's snapshot and returns whether there was one to drop.
84
+ # Deleting a source that was never stored is not an error: it is the
85
+ # state the caller asked for.
86
+ sig { params(_source: T.untyped).returns(T::Boolean) }
87
+ def delete_snapshot(_source)
88
+ raise UnsupportedError, "#{self.class} must implement #delete_snapshot(source)"
89
+ end
90
+
91
+ # Every source with a stored snapshot, sorted, so a CLI listing and a
92
+ # sync summary do not reshuffle themselves between runs.
93
+ sig { returns(T::Array[Symbol]) }
94
+ def sources
95
+ raise UnsupportedError, "#{self.class} must implement #sources"
96
+ end
97
+
98
+ # What is stored for a source without reading the list: fetched_at,
99
+ # checksum, record_count. Nil when nothing is stored.
100
+ #
101
+ # Derived here by reading the snapshot, which is correct but is the thing
102
+ # Meta exists to avoid. An adapter that keeps this separately -- #24's
103
+ # `meta.json` sidecar, a metadata row -- overrides it and answers without
104
+ # deserializing tens of megabytes to print an age.
105
+ sig { params(source: T.untyped).returns(T.nilable(Meta)) }
106
+ def snapshot_meta(source)
107
+ snapshot = read_snapshot(source)
108
+ snapshot && Meta.from_snapshot(snapshot)
109
+ end
110
+
111
+ # The stored snapshot, raising when there is none. For a caller that
112
+ # named the source itself and cannot do its job without it -- screening
113
+ # against a list that is not there returns a clean report, which is the
114
+ # most expensive thing this library can get wrong.
115
+ sig { params(source: T.untyped).returns(Snapshot) }
116
+ def fetch_snapshot(source)
117
+ key = source_key!(source)
118
+ read_snapshot(key) || raise(MissingSnapshot.new(missing_message(key), source_id: key))
119
+ end
120
+
121
+ # Every entity from every stored list, or from the ones named:
122
+ #
123
+ # store.each_entity { |entity| index.add(entity) }
124
+ # store.each_entity(sources: %i[ofac_sdn]).lazy.select { |e| e.type == :vessel }
125
+ #
126
+ # An Enumerator without a block, and it streams: the index build (#31)
127
+ # walks every entity of every list, and materializing an array of ~25,000
128
+ # entities across every source before the first one is yielded is a cost
129
+ # nothing here needs to pay. Snapshots are read one at a time and each is
130
+ # released before the next is opened.
131
+ #
132
+ # Naming sources changes what a missing one means. `sources: nil` asks
133
+ # for whatever is stored, where there is nothing to be missing; naming a
134
+ # list that has never been synced raises MissingSnapshot rather than
135
+ # yielding fewer entities, because a screening run that quietly covers
136
+ # two of the three lists it was configured with is indistinguishable from
137
+ # one that covers all three.
138
+ sig do
139
+ params(sources: T.untyped, block: T.nilable(T.proc.params(entity: T.untyped).void)).returns(T.untyped)
140
+ end
141
+ def each_entity(sources: nil, &block)
142
+ return enum_for(:each_entity, sources: sources) unless block
143
+
144
+ each_snapshot(sources) { |snapshot| snapshot.entities.each(&block) }
145
+ self
146
+ end
147
+
148
+ # Whether a source has a stored snapshot. Answered off #sources rather
149
+ # than by reading one, so asking is cheap for every adapter.
150
+ sig { params(source: T.untyped).returns(T::Boolean) }
151
+ def stored?(source) = sources.include?(source_key!(source))
152
+
153
+ sig { returns(Integer) }
154
+ def size = sources.size
155
+
156
+ sig { returns(T::Boolean) }
157
+ def empty? = sources.empty?
158
+
159
+ # Drops every snapshot and returns the store. Deliberately spelled out
160
+ # rather than implemented as a truncation, so an adapter only ever has to
161
+ # get one deletion path right.
162
+ sig { returns(T.self_type) }
163
+ def clear
164
+ sources.each { |source| delete_snapshot(source) }
165
+ self
166
+ end
167
+
168
+ sig { returns(String) }
169
+ def inspect = "#<#{self.class} #{list}>"
170
+
171
+ private
172
+
173
+ # The snapshots a caller asked for, in the order they were asked for.
174
+ sig { params(named: T.untyped, block: T.proc.params(snapshot: Snapshot).void).void }
175
+ def each_snapshot(named, &block)
176
+ return Array(named).each { |source| block.call(fetch_snapshot(source)) } unless named.nil?
177
+
178
+ sources.each do |source|
179
+ snapshot = read_snapshot(source)
180
+ block.call(snapshot) if snapshot
181
+ end
182
+ end
183
+
184
+ # The same rule the registry holds a source key to, applied here for the
185
+ # reason that rule exists: a key is typed by a human -- into
186
+ # configuration, into a CLI argument -- and it is a directory name to
187
+ # every adapter that writes files. Sharing it means #24 cannot be handed
188
+ # a key that escapes its root, and it means a name that is not a source
189
+ # fails the same way wherever it is typed.
190
+ sig { params(value: T.untyped).returns(Symbol) }
191
+ def source_key!(value) = Sources::Definition.key!(value)
192
+
193
+ # A Snapshot and not merely something snapshot-shaped. What makes a
194
+ # stored list auditable is that its checksum was computed over its own
195
+ # content by the class that knows how; a hash of the right shape carries
196
+ # a checksum somebody typed.
197
+ sig { params(value: T.untyped).returns(Snapshot) }
198
+ def snapshot!(value)
199
+ unless value.is_a?(Snapshot)
200
+ raise InvalidArgument, "write_snapshot takes an ActiveSanction::Snapshot, got #{value.class}"
201
+ end
202
+
203
+ value
204
+ end
205
+
206
+ sig { params(key: Symbol).returns(String) }
207
+ def missing_message(key)
208
+ "no snapshot stored for #{key.inspect}. Stored: #{list}. A source has to be synced before it can be " \
209
+ "screened against -- ActiveSanction::Sources[#{key.inspect}].new.sync"
210
+ end
211
+
212
+ sig { returns(String) }
213
+ def list = empty? ? "(nothing)" : sources.join(", ")
214
+ end
215
+ end
216
+ end