njtransit 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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/njtransit.md +196 -0
  3. data/.mcp.json.example +12 -0
  4. data/.mcp.json.sample +11 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +87 -0
  7. data/.ruby-version +1 -0
  8. data/CHANGELOG.md +37 -0
  9. data/CLAUDE.md +159 -0
  10. data/CODE_OF_CONDUCT.md +84 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +148 -0
  13. data/Rakefile +12 -0
  14. data/docs/plans/2025-01-24-njtransit-gem-design.md +112 -0
  15. data/docs/plans/2026-01-24-bus-api-design.md +119 -0
  16. data/docs/plans/2026-01-24-gtfs-implementation.md +2216 -0
  17. data/docs/plans/2026-01-24-gtfs-loader-design.md +351 -0
  18. data/docs/superpowers/plans/2026-03-26-dev-infra-and-agent.md +480 -0
  19. data/lefthook.yml +17 -0
  20. data/lib/njtransit/client.rb +291 -0
  21. data/lib/njtransit/configuration.rb +49 -0
  22. data/lib/njtransit/error.rb +50 -0
  23. data/lib/njtransit/gtfs/database.rb +145 -0
  24. data/lib/njtransit/gtfs/importer.rb +124 -0
  25. data/lib/njtransit/gtfs/models/route.rb +59 -0
  26. data/lib/njtransit/gtfs/models/stop.rb +63 -0
  27. data/lib/njtransit/gtfs/queries/routes_between.rb +62 -0
  28. data/lib/njtransit/gtfs/queries/schedule.rb +75 -0
  29. data/lib/njtransit/gtfs.rb +119 -0
  30. data/lib/njtransit/railtie.rb +9 -0
  31. data/lib/njtransit/resources/base.rb +35 -0
  32. data/lib/njtransit/resources/bus/enrichment.rb +105 -0
  33. data/lib/njtransit/resources/bus.rb +95 -0
  34. data/lib/njtransit/resources/bus_gtfs.rb +34 -0
  35. data/lib/njtransit/resources/rail.rb +47 -0
  36. data/lib/njtransit/resources/rail_gtfs.rb +27 -0
  37. data/lib/njtransit/tasks.rb +74 -0
  38. data/lib/njtransit/version.rb +5 -0
  39. data/lib/njtransit.rb +40 -0
  40. data/sig/njtransit.rbs +4 -0
  41. metadata +177 -0
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/typhoeus"
5
+ require "faraday/multipart"
6
+ require "json"
7
+
8
+ require_relative "resources/base"
9
+ require_relative "resources/bus"
10
+ require_relative "resources/rail"
11
+ require_relative "resources/bus_gtfs"
12
+ require_relative "resources/rail_gtfs"
13
+
14
+ module NJTransit
15
+ class Client
16
+ DEFAULT_AUTH_PATH = "/api/BUSDV2/authenticateUser"
17
+
18
+ attr_reader :username, :password, :log_level, :base_url, :timeout, :auth_path
19
+
20
+ def initialize(username:, password:, log_level: "silent", base_url: Configuration::DEFAULT_BASE_URL,
21
+ timeout: Configuration::DEFAULT_TIMEOUT, auth_path: DEFAULT_AUTH_PATH)
22
+ @username = username
23
+ @password = password
24
+ @log_level = log_level
25
+ @base_url = base_url
26
+ @timeout = timeout
27
+ @auth_path = auth_path
28
+ @token = nil
29
+ end
30
+
31
+ def bus
32
+ @bus ||= Resources::Bus.new(self)
33
+ end
34
+
35
+ def rail
36
+ @rail ||= Resources::Rail.new(self)
37
+ end
38
+
39
+ def bus_gtfs
40
+ @bus_gtfs ||= Resources::BusGTFS.new(self)
41
+ end
42
+
43
+ def bus_gtfs_g2
44
+ @bus_gtfs_g2 ||= Resources::BusGTFS.new(self, api_prefix: "/api/GTFSG2")
45
+ end
46
+
47
+ def rail_gtfs
48
+ @rail_gtfs ||= Resources::RailGTFS.new(self)
49
+ end
50
+
51
+ def get(path, params = {})
52
+ request(:get, path, params)
53
+ end
54
+
55
+ def post(path, body = {})
56
+ request(:post, path, body)
57
+ end
58
+
59
+ def post_form(path, params = {})
60
+ request_form(:post, path, params)
61
+ end
62
+
63
+ def put(path, body = {})
64
+ request(:put, path, body)
65
+ end
66
+
67
+ def patch(path, body = {})
68
+ request(:patch, path, body)
69
+ end
70
+
71
+ def delete(path, params = {})
72
+ request(:delete, path, params)
73
+ end
74
+
75
+ def post_form_raw(path, params = {})
76
+ request_form_raw(:post, path, params)
77
+ end
78
+
79
+ def authenticate!
80
+ cached = self.class.token_cache[base_url]
81
+ if cached
82
+ @token = cached
83
+ return @token
84
+ end
85
+
86
+ response = form_connection.post(auth_path) do |req|
87
+ req.body = { username: username, password: password }
88
+ end
89
+
90
+ result = parse_body(response.body)
91
+
92
+ unless result.is_a?(Hash) && result["Authenticated"] == "True"
93
+ message = result.is_a?(Hash) && result["errorMessage"] ? result["errorMessage"] : "Authentication failed"
94
+ raise AuthenticationError, message
95
+ end
96
+
97
+ @token = result["UserToken"]
98
+ self.class.token_cache[base_url] = @token
99
+ @token
100
+ end
101
+
102
+ def token
103
+ authenticate! if @token.nil?
104
+ @token
105
+ end
106
+
107
+ def clear_token!
108
+ @token = nil
109
+ self.class.token_cache.delete(base_url)
110
+ end
111
+
112
+ def self.token_cache
113
+ @token_cache ||= {}
114
+ end
115
+
116
+ def self.clear_token_cache!
117
+ @token_cache = {}
118
+ end
119
+
120
+ private
121
+
122
+ def request(method, path, params_or_body = {})
123
+ response = json_connection.public_send(method) do |req|
124
+ req.url(path)
125
+ if %i[get delete].include?(method)
126
+ req.params = params_or_body
127
+ else
128
+ req.body = JSON.generate(params_or_body)
129
+ end
130
+ end
131
+
132
+ handle_response(response)
133
+ rescue Faraday::TimeoutError => e
134
+ raise TimeoutError, e.message
135
+ rescue Faraday::ConnectionFailed => e
136
+ raise ConnectionError, e.message
137
+ end
138
+
139
+ def request_form(method, path, params = {}, retry_auth: true)
140
+ response = form_connection.public_send(method) do |req|
141
+ req.url(path)
142
+ req.body = params
143
+ end
144
+
145
+ result = handle_response(response)
146
+
147
+ if token_expired?(result) && retry_auth
148
+ clear_token!
149
+ authenticate!
150
+ params[:token] = @token
151
+ return request_form(method, path, params, retry_auth: false)
152
+ end
153
+
154
+ check_api_error!(result)
155
+ result
156
+ rescue Faraday::TimeoutError => e
157
+ raise TimeoutError, e.message
158
+ rescue Faraday::ConnectionFailed => e
159
+ raise ConnectionError, e.message
160
+ end
161
+
162
+ def request_form_raw(method, path, params = {})
163
+ params[:token] = token
164
+ response = raw_connection.public_send(method) do |req|
165
+ req.url(path)
166
+ req.body = params
167
+ end
168
+
169
+ raise error_for_status(response.status).new(error_message(response), response: response) unless response.success?
170
+
171
+ response.body
172
+ rescue Faraday::TimeoutError => e
173
+ raise TimeoutError, e.message
174
+ rescue Faraday::ConnectionFailed => e
175
+ raise ConnectionError, e.message
176
+ end
177
+
178
+ def token_expired?(result)
179
+ result.is_a?(Hash) && result["errorMessage"] == "Invalid token."
180
+ end
181
+
182
+ def check_api_error!(result)
183
+ return unless result.is_a?(Hash) && result["errorMessage"]
184
+
185
+ raise APIError, result["errorMessage"]
186
+ end
187
+
188
+ def json_connection
189
+ @json_connection ||= Faraday.new(url: base_url) do |f|
190
+ f.request :json
191
+ f.response :logger, logger, { headers: log_headers?, bodies: log_bodies? } if logging_enabled?
192
+ f.adapter :typhoeus
193
+ f.options.timeout = timeout
194
+ f.options.open_timeout = timeout
195
+ f.headers["Content-Type"] = "application/json"
196
+ f.headers["Accept"] = "application/json"
197
+ end
198
+ end
199
+
200
+ def form_connection
201
+ @form_connection ||= Faraday.new(url: base_url) do |f|
202
+ f.request :multipart
203
+ f.request :url_encoded
204
+ f.response :logger, logger, { headers: log_headers?, bodies: log_bodies? } if logging_enabled?
205
+ f.adapter :typhoeus
206
+ f.options.timeout = timeout
207
+ f.options.open_timeout = timeout
208
+ f.headers["Accept"] = "text/plain"
209
+ end
210
+ end
211
+
212
+ def raw_connection
213
+ @raw_connection ||= Faraday.new(url: base_url) do |f|
214
+ f.request :multipart
215
+ f.request :url_encoded
216
+ f.adapter :typhoeus
217
+ f.options.timeout = timeout
218
+ f.options.open_timeout = timeout
219
+ f.headers["Accept"] = "*/*"
220
+ end
221
+ end
222
+
223
+ def handle_response(response)
224
+ return parse_body(response.body) if response.success?
225
+
226
+ raise error_for_status(response.status).new(
227
+ error_message(response),
228
+ response: response
229
+ )
230
+ end
231
+
232
+ def parse_body(body)
233
+ return nil if body.nil? || body.empty?
234
+
235
+ JSON.parse(body)
236
+ rescue JSON::ParserError
237
+ body
238
+ end
239
+
240
+ def error_message(response)
241
+ parsed = parse_body(response.body)
242
+ if parsed.is_a?(Hash)
243
+ parsed["error"] || parsed["message"] || parsed["errorMessage"] || response.body
244
+ else
245
+ response.body
246
+ end
247
+ end
248
+
249
+ def error_for_status(status)
250
+ case status
251
+ when 400 then BadRequestError
252
+ when 401 then AuthenticationError
253
+ when 403 then ForbiddenError
254
+ when 404 then NotFoundError
255
+ when 405 then MethodNotAllowedError
256
+ when 409 then ConflictError
257
+ when 410 then GoneError
258
+ when 422 then UnprocessableEntityError
259
+ when 429 then RateLimitError
260
+ when 500 then InternalServerError
261
+ when 502 then BadGatewayError
262
+ when 503 then ServiceUnavailableError
263
+ when 504 then GatewayTimeoutError
264
+ when 400..499 then ClientError
265
+ when 500..599 then ServerError
266
+ else Error
267
+ end
268
+ end
269
+
270
+ def logging_enabled?
271
+ %w[info debug].include?(log_level)
272
+ end
273
+
274
+ def log_headers?
275
+ log_level == "debug"
276
+ end
277
+
278
+ def log_bodies?
279
+ log_level == "debug"
280
+ end
281
+
282
+ def logger
283
+ @logger ||= Logger.new($stdout).tap do |log|
284
+ log.level = log_level == "debug" ? Logger::DEBUG : Logger::INFO
285
+ log.formatter = proc do |severity, _datetime, _progname, msg|
286
+ "[NJTransit #{severity}] #{msg}\n"
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NJTransit
4
+ class Configuration
5
+ VALID_LOG_LEVELS = %w[silent info debug].freeze
6
+ DEFAULT_BASE_URL = "https://pcsdata.njtransit.com"
7
+ DEFAULT_RAIL_BASE_URL = "https://raildata.njtransit.com"
8
+ DEFAULT_TIMEOUT = 30
9
+
10
+ attr_accessor :username, :password, :base_url, :timeout, :gtfs_database_path
11
+ attr_reader :log_level
12
+
13
+ def initialize
14
+ @username = ENV.fetch("NJTRANSIT_USERNAME", nil)
15
+ @password = ENV.fetch("NJTRANSIT_PASSWORD", nil)
16
+ @log_level = ENV.fetch("NJTRANSIT_LOG_LEVEL", "silent")
17
+ @base_url = ENV.fetch("NJTRANSIT_BASE_URL", DEFAULT_BASE_URL)
18
+ @timeout = ENV.fetch("NJTRANSIT_TIMEOUT", DEFAULT_TIMEOUT).to_i
19
+ @gtfs_database_path = ENV.fetch("NJTRANSIT_GTFS_DATABASE_PATH", nil) || default_gtfs_database_path
20
+ end
21
+
22
+ def log_level=(level)
23
+ level = level.to_s.downcase
24
+ unless VALID_LOG_LEVELS.include?(level)
25
+ raise ArgumentError,
26
+ "Invalid log level: #{level}. Valid levels: #{VALID_LOG_LEVELS.join(", ")}"
27
+ end
28
+
29
+ @log_level = level
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ username: username,
35
+ password: password,
36
+ log_level: log_level,
37
+ base_url: base_url,
38
+ timeout: timeout
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def default_gtfs_database_path
45
+ base = ENV["XDG_DATA_HOME"] || File.expand_path("~/.local/share")
46
+ File.join(base, "njtransit", "gtfs.sqlite3")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NJTransit
4
+ class Error < StandardError
5
+ attr_reader :response
6
+
7
+ def initialize(message = nil, response: nil)
8
+ @response = response
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ # API-level errors (returned in response body)
14
+ class APIError < Error; end
15
+
16
+ # Client errors (4xx)
17
+ class ClientError < Error; end
18
+ class BadRequestError < ClientError; end # 400
19
+ class AuthenticationError < ClientError; end # 401
20
+ class ForbiddenError < ClientError; end # 403
21
+ class NotFoundError < ClientError; end # 404
22
+ class MethodNotAllowedError < ClientError; end # 405
23
+ class ConflictError < ClientError; end # 409
24
+ class GoneError < ClientError; end # 410
25
+ class UnprocessableEntityError < ClientError; end # 422
26
+ class RateLimitError < ClientError; end # 429
27
+
28
+ # Server errors (5xx)
29
+ class ServerError < Error; end
30
+ class InternalServerError < ServerError; end # 500
31
+ class BadGatewayError < ServerError; end # 502
32
+ class ServiceUnavailableError < ServerError; end # 503
33
+ class GatewayTimeoutError < ServerError; end # 504
34
+
35
+ # Connection issues
36
+ class ConnectionError < Error; end
37
+ class TimeoutError < ConnectionError; end
38
+
39
+ # GTFS not imported
40
+ class GTFSNotImportedError < Error
41
+ def initialize(detected_path: nil)
42
+ message = "GTFS data not found. Run: rake njtransit:gtfs:import[/path/to/bus_data]"
43
+ if detected_path
44
+ message += "\n\nDetected GTFS files at: #{detected_path}"
45
+ message += "\nHint: rake njtransit:gtfs:import[#{detected_path}]"
46
+ end
47
+ super(message)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "fileutils"
5
+
6
+ module NJTransit
7
+ module GTFS
8
+ # Database module for managing GTFS SQLite storage
9
+ module Database
10
+ class << self
11
+ def connection(path = nil)
12
+ @path = path if path
13
+ @connection ||= begin
14
+ FileUtils.mkdir_p(File.dirname(@path))
15
+ Sequel.sqlite(@path)
16
+ end
17
+ end
18
+
19
+ def disconnect
20
+ @connection&.disconnect
21
+ @connection = nil
22
+ end
23
+
24
+ def exists?(path)
25
+ return false unless File.exist?(path)
26
+
27
+ db = Sequel.sqlite(path)
28
+ db.table_exists?(:agencies) && db.table_exists?(:stops)
29
+ rescue StandardError
30
+ false
31
+ ensure
32
+ db&.disconnect
33
+ end
34
+
35
+ def setup_schema!
36
+ create_agencies_table
37
+ create_routes_table
38
+ create_stops_table
39
+ create_trips_table
40
+ create_stop_times_table
41
+ create_calendar_dates_table
42
+ create_shapes_table
43
+ create_import_metadata_table
44
+ end
45
+
46
+ def clear!
47
+ db = connection
48
+ %i[agencies routes stops trips stop_times calendar_dates shapes import_metadata].each do |table|
49
+ db.drop_table?(table)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def create_agencies_table
56
+ connection.create_table?(:agencies) do
57
+ String :agency_id, primary_key: true
58
+ String :agency_name
59
+ String :agency_url
60
+ String :agency_timezone
61
+ end
62
+ end
63
+
64
+ def create_routes_table
65
+ connection.create_table?(:routes) do
66
+ String :route_id, primary_key: true
67
+ String :agency_id
68
+ String :route_short_name
69
+ String :route_long_name
70
+ Integer :route_type
71
+ String :route_color
72
+ index :route_short_name
73
+ end
74
+ end
75
+
76
+ def create_stops_table
77
+ connection.create_table?(:stops) do
78
+ String :stop_id, primary_key: true
79
+ String :stop_code
80
+ String :stop_name
81
+ Float :stop_lat
82
+ Float :stop_lon
83
+ String :zone_id
84
+ index :stop_code
85
+ end
86
+ end
87
+
88
+ def create_trips_table
89
+ connection.create_table?(:trips) do
90
+ String :trip_id, primary_key: true
91
+ String :route_id
92
+ String :service_id
93
+ String :trip_headsign
94
+ Integer :direction_id
95
+ String :shape_id
96
+ index :route_id
97
+ index :service_id
98
+ end
99
+ end
100
+
101
+ def create_stop_times_table
102
+ connection.create_table?(:stop_times) do
103
+ primary_key :id
104
+ String :trip_id
105
+ String :stop_id
106
+ String :arrival_time
107
+ String :departure_time
108
+ Integer :stop_sequence
109
+ index :trip_id
110
+ index :stop_id
111
+ end
112
+ end
113
+
114
+ def create_calendar_dates_table
115
+ connection.create_table?(:calendar_dates) do
116
+ primary_key :id
117
+ String :service_id
118
+ String :date
119
+ Integer :exception_type
120
+ index %i[service_id date]
121
+ end
122
+ end
123
+
124
+ def create_shapes_table
125
+ connection.create_table?(:shapes) do
126
+ primary_key :id
127
+ String :shape_id
128
+ Float :shape_pt_lat
129
+ Float :shape_pt_lon
130
+ Integer :shape_pt_sequence
131
+ index :shape_id
132
+ end
133
+ end
134
+
135
+ def create_import_metadata_table
136
+ connection.create_table?(:import_metadata) do
137
+ primary_key :id
138
+ DateTime :imported_at
139
+ String :source_path
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module NJTransit
6
+ module GTFS
7
+ class Importer
8
+ REQUIRED_FILES = %w[agency.txt routes.txt stops.txt].freeze
9
+ OPTIONAL_FILES = %w[trips.txt stop_times.txt calendar_dates.txt shapes.txt].freeze
10
+
11
+ # Table configurations: [filename, table, batch_size, field_mapper]
12
+ TABLE_CONFIGS = {
13
+ agencies: ["agency.txt", 1000, ->(r) { agency_fields(r) }],
14
+ routes: ["routes.txt", 1000, ->(r) { route_fields(r) }],
15
+ stops: ["stops.txt", 1000, ->(r) { stop_fields(r) }],
16
+ trips: ["trips.txt", 1000, ->(r) { trip_fields(r) }],
17
+ stop_times: ["stop_times.txt", 10_000, ->(r) { stop_time_fields(r) }],
18
+ calendar_dates: ["calendar_dates.txt", 1000, ->(r) { calendar_date_fields(r) }],
19
+ shapes: ["shapes.txt", 50_000, ->(r) { shape_fields(r) }]
20
+ }.freeze
21
+
22
+ attr_reader :source_path, :db_path
23
+
24
+ def initialize(source_path, db_path)
25
+ @source_path = source_path
26
+ @db_path = db_path
27
+ end
28
+
29
+ def import(force: false)
30
+ validate_can_import!(force)
31
+ prepare_database(force)
32
+ import_all_tables
33
+ record_metadata
34
+ end
35
+
36
+ def valid_gtfs_directory?
37
+ return false unless File.directory?(source_path)
38
+
39
+ REQUIRED_FILES.all? { |f| File.exist?(File.join(source_path, f)) }
40
+ end
41
+
42
+ class << self
43
+ def agency_fields(row)
44
+ { agency_id: row["agency_id"], agency_name: row["agency_name"],
45
+ agency_url: row["agency_url"], agency_timezone: row["agency_timezone"] }
46
+ end
47
+
48
+ def route_fields(row)
49
+ { route_id: row["route_id"], agency_id: row["agency_id"],
50
+ route_short_name: row["route_short_name"], route_long_name: row["route_long_name"],
51
+ route_type: row["route_type"]&.to_i, route_color: row["route_color"] }
52
+ end
53
+
54
+ def stop_fields(row)
55
+ { stop_id: row["stop_id"], stop_code: row["stop_code"], stop_name: row["stop_name"],
56
+ stop_lat: row["stop_lat"]&.to_f, stop_lon: row["stop_lon"]&.to_f, zone_id: row["zone_id"] }
57
+ end
58
+
59
+ def trip_fields(row)
60
+ { trip_id: row["trip_id"], route_id: row["route_id"], service_id: row["service_id"],
61
+ trip_headsign: row["trip_headsign"], direction_id: row["direction_id"]&.to_i, shape_id: row["shape_id"] }
62
+ end
63
+
64
+ def stop_time_fields(row)
65
+ { trip_id: row["trip_id"], stop_id: row["stop_id"], arrival_time: row["arrival_time"],
66
+ departure_time: row["departure_time"], stop_sequence: row["stop_sequence"]&.to_i }
67
+ end
68
+
69
+ def calendar_date_fields(row)
70
+ { service_id: row["service_id"], date: row["date"], exception_type: row["exception_type"]&.to_i }
71
+ end
72
+
73
+ def shape_fields(row)
74
+ { shape_id: row["shape_id"], shape_pt_lat: row["shape_pt_lat"]&.to_f,
75
+ shape_pt_lon: row["shape_pt_lon"]&.to_f, shape_pt_sequence: row["shape_pt_sequence"]&.to_i }
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def validate_can_import!(force)
82
+ return unless Database.exists?(db_path) && !force
83
+
84
+ raise NJTransit::Error, "GTFS database already exists at #{db_path}. Use force: true to reimport."
85
+ end
86
+
87
+ def prepare_database(force)
88
+ Database.disconnect
89
+ FileUtils.rm_f(db_path) if force
90
+ Database.connection(db_path)
91
+ Database.setup_schema!
92
+ end
93
+
94
+ def import_all_tables
95
+ TABLE_CONFIGS.each do |table, (filename, batch_size, mapper)|
96
+ import_csv(filename, table, batch_size: batch_size, &mapper)
97
+ end
98
+ end
99
+
100
+ def import_csv(filename, table, batch_size: 1000)
101
+ path = File.join(source_path, filename)
102
+ return unless File.exist?(path)
103
+
104
+ batch = []
105
+ CSV.foreach(path, headers: true) do |row|
106
+ batch << yield(row)
107
+ flush_batch(table, batch) if batch.size >= batch_size
108
+ end
109
+ flush_batch(table, batch)
110
+ end
111
+
112
+ def flush_batch(table, batch)
113
+ return if batch.empty?
114
+
115
+ Database.connection[table].multi_insert(batch)
116
+ batch.clear
117
+ end
118
+
119
+ def record_metadata
120
+ Database.connection[:import_metadata].insert(imported_at: Time.now, source_path: source_path)
121
+ end
122
+ end
123
+ end
124
+ end