runlight 0.1.1 → 0.1.3

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.
@@ -222,6 +222,103 @@ module Runlight
222
222
  end
223
223
  end
224
224
 
225
+ # Headers that describe a body, dropped with it when a redirect turns a request into a GET.
226
+ BODY_HEADERS = %w[content-type content-length content-encoding content-language content-location].freeze
227
+
228
+ # Fetches from a service someone set up, such as an AI model's API or a
229
+ # mail webhook, which may well be on this computer or a private network,
230
+ # but is never at a link-local or cloud metadata address. The address is
231
+ # checked, and so is every redirect's, up to "redirects" of them (default
232
+ # 5), each followed as fetch follows it: a 303, or a 301 or 302 answering a
233
+ # POST, goes on as a GET with no body. Only a redirect on the same origin is
234
+ # followed: the request carries a key, in whatever header the service names
235
+ # it, and a 307 or 308 would carry its body too, an email or a conversation, so
236
+ # a redirect to another origin, https to http among them, is refused before
237
+ # anything is sent there. A redirect past the last one comes back as it is.
238
+ # Raises a MetadataAddressError or a RedirectRefusedError for an address it refuses.
239
+ #
240
+ # init: a Hash with optionally "method", "headers" (a Hash), "body", "timeoutMs" (for every hop together),
241
+ # "redirects", and "maxBytes" and "truncate" for the fetcher.
242
+ def service_fetch(target, init, fetcher = nil)
243
+ fetcher ||= Http::NetFetcher.new
244
+ # A fetcher that reaches the network is held to the address checked. One a test or an app supplies is
245
+ # not, since a made-up host may not resolve anywhere.
246
+ strict = fetcher.is_a?(Http::NetFetcher)
247
+ redirects = init["redirects"] || 5
248
+ url = Http::Url.parse(target)
249
+ raise Http::FetchError, "Invalid URL" if url.nil?
250
+
251
+ method = (init["method"] || "GET").to_s.upcase
252
+ headers = (init["headers"] || {}).to_h
253
+ body = init["body"]
254
+ until_ns = init["timeoutMs"] && monotonic + (init["timeoutMs"] * 1_000_000)
255
+ hop = 0
256
+ loop do
257
+ host = url.hostname.sub(/\A\[/, "").sub(/\]\z/, "")
258
+ pin = service_pin(url, host, strict)
259
+
260
+ options = { "method" => method, "headers" => headers, "redirect" => "manual" }
261
+ options["resolve"] = pin unless pin.nil?
262
+ options["body"] = body unless body.nil?
263
+ %w[maxBytes truncate].each { |key| options[key] = init[key] unless init[key].nil? }
264
+ unless until_ns.nil?
265
+ # The whole limit for the first hop, and what is left of it for each redirect.
266
+ left = hop.zero? ? init["timeoutMs"] : (until_ns - monotonic).div(1_000_000)
267
+ raise timed_out if left <= 0
268
+
269
+ options["timeoutMs"] = left
270
+ end
271
+ answer = fetcher.fetch(url.href, options)
272
+ location = answer.headers.get("location")
273
+ return answer if answer.status < 300 || answer.status >= 400 || location.nil? || location == "" || hop >= redirects
274
+
275
+ following = Http::Url.parse(location, url.href)
276
+ raise Http::FetchError, "Invalid URL" if following.nil?
277
+
278
+ if following.origin != url.origin
279
+ there = following.hostname.sub(/\A\[/, "").sub(/\]\z/, "")
280
+ raise MetadataAddressError, there if metadata_host?(there)
281
+
282
+ raise RedirectRefusedError.new(url.host, %w[http: https:].include?(following.protocol) ? following.origin : following.protocol)
283
+ end
284
+ if answer.status == 303 || ([301, 302].include?(answer.status) && method == "POST")
285
+ method = "GET" unless method == "HEAD"
286
+ body = nil
287
+ headers = headers.reject { |name, _| BODY_HEADERS.include?(name.to_s.downcase) }
288
+ end
289
+ url = following
290
+ hop += 1
291
+ end
292
+ end
293
+
294
+ # The pin for one hop of a service fetch: the name is resolved here, every address it gives is checked, and
295
+ # the connection is pinned to them (the fetcher's "resolve", which also keeps it off any proxy from the
296
+ # environment), so a second lookup cannot hand back another. A private or local address is fine, since a
297
+ # model or a mail relay may be there; a link-local or metadata one is refused. A name that does not resolve
298
+ # is refused when strict, and let through to the fetcher otherwise.
299
+ def service_pin(url, host, strict)
300
+ name = Js.lower(Js.trim(host)).sub(/\.+\z/, "")
301
+ raise MetadataAddressError, host if METADATA_NAMES.include?(name) || metadata_address?(name)
302
+
303
+ literal = !v4(name).nil? || !v6(name).nil?
304
+ addresses = begin
305
+ literal ? [name] : Safefetch.resolver.call(name).map(&:to_s)
306
+ rescue StandardError
307
+ []
308
+ end
309
+ if addresses.empty?
310
+ raise Http::FetchError, "Could not look up #{host}" if strict
311
+
312
+ return nil
313
+ end
314
+ raise MetadataAddressError, host if addresses.any? { |address| metadata_address?(address) }
315
+
316
+ port = url.port == "" ? { "https:" => "443", "http:" => "80" }[url.protocol] : url.port
317
+ return nil if port.nil?
318
+
319
+ ["#{name}:#{port}:#{addresses.map { |a| a.include?(":") ? "[#{a}]" : a }.join(",")}"]
320
+ end
321
+
225
322
  # An install on this machine: http://localhost or http://127.0.0.1, with any port.
226
323
  LOCAL_INSTALL = %r{\Ahttp://(localhost|127\.0\.0\.1)(:\d+)?(/|\z)}
227
324
 
@@ -250,6 +347,6 @@ module Runlight
250
347
  Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
251
348
  end
252
349
 
253
- private_class_method :v4, :public_v4?, :v6, :timed_out, :monotonic
350
+ private_class_method :v4, :public_v4?, :v6, :timed_out, :monotonic, :service_pin
254
351
  end
255
352
  end
@@ -191,7 +191,7 @@ module Runlight
191
191
  raise SendError, e.message
192
192
  end
193
193
  if answer.status == 401
194
- raise SendError, "Runlight refused the key. Use the site's key from Settings, Install, Key for CMS plugins."
194
+ raise SendError, "Runlight refused the key. Use the site's agents key from Settings, Install."
195
195
  end
196
196
  raise SendError, "Runlight answered #{answer.status}: #{Js.slice(Js.scrub(answer.text), 0, 200)}" unless answer.ok?
197
197
 
@@ -42,7 +42,7 @@ module Runlight
42
42
 
43
43
  --log <file> The access log, in nginx or Apache's combined format, or Caddy's JSON
44
44
  --to <url> Your Runlight, as its dashboard address (or RUNLIGHT_URL)
45
- --key <key> The site's key from Settings, Install, Key for CMS plugins (or RUNLIGHT_OBSERVE_KEY)
45
+ --key <key> The site's agents key from Settings, Install (or RUNLIGHT_OBSERVE_KEY)
46
46
  --site <url> The site's address, such as https://example.com, when the log has no host in it
47
47
  --follow Keep running and send fetches as they happen
48
48
  --state <file> Remember where it stopped, so the next run, or a restarted --follow, starts there.
@@ -101,25 +101,7 @@ module Runlight
101
101
  given = get("RUNLIGHT_SECRET")
102
102
  return @secret = given unless given.nil?
103
103
 
104
- file = "#{data_dir}/secret"
105
- saved = read(file)
106
- return @secret = saved if saved != ""
107
-
108
- made = SecureRandom.hex(32)
109
- # Made once: whoever writes the file first wins, and everyone else reads theirs.
110
- begin
111
- File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
112
- rescue SystemCallError
113
- sleep(0.05)
114
- saved = read(file)
115
- if saved == ""
116
- raise RuntimeError, "Runlight: could not write #{file}. Make the data folder writable, or set RUNLIGHT_SECRET."
117
- end
118
-
119
- return @secret = saved
120
- end
121
- File.chmod(0o600, file)
122
- @secret = made
104
+ @secret = secret_in(data_dir)
123
105
  end
124
106
 
125
107
  # The dashboard's public address, such as https://stats.example.com, or nil.
@@ -234,6 +216,69 @@ module Runlight
234
216
  end
235
217
 
236
218
  # A file's text, trimmed unless asked not to, or "" when there is none.
219
+ # Shorter than any secret this file has ever been given, so a file caught half written is never used.
220
+ SHORTEST_SECRET = 32
221
+
222
+ # The secret kept in `dir`, made on first run, readable only by this user. Servers starting at once on one
223
+ # folder agree on it: the new secret is written whole to a file of its own, then linked into place, which
224
+ # refuses to replace a file already there, and the one that loses reads the winner's. A file read empty or
225
+ # short is read again for a moment, and then refused, never used.
226
+ def secret_in(dir)
227
+ file = "#{dir}/secret"
228
+ found = whole_secret(file)
229
+ return found unless found.nil?
230
+
231
+ made = SecureRandom.hex(32)
232
+ temp = "#{dir}/.secret-#{Process.pid}-#{SecureRandom.hex(6)}"
233
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
234
+ begin
235
+ File.chmod(0o600, temp)
236
+ begin
237
+ File.link(temp, file)
238
+ made
239
+ rescue Errno::EEXIST
240
+ whole_secret(file) || refuse_secret(file)
241
+ rescue SystemCallError
242
+ # A file system with no hard links still creates the file only if it is not there; a reader that
243
+ # catches it before its contents land reads it again.
244
+ begin
245
+ File.open(file, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |out| out.write("#{made}\n") }
246
+ File.chmod(0o600, file)
247
+ made
248
+ rescue Errno::EEXIST
249
+ whole_secret(file) || refuse_secret(file)
250
+ rescue SystemCallError
251
+ raise RuntimeError, "Runlight: could not write #{file}. Make the data folder writable, or set RUNLIGHT_SECRET."
252
+ end
253
+ end
254
+ ensure
255
+ begin
256
+ File.unlink(temp)
257
+ rescue SystemCallError
258
+ # Gone already.
259
+ end
260
+ end
261
+ end
262
+
263
+ # The secret in a file, waiting up to two seconds for one too short to be whole; nil when there is no file.
264
+ def whole_secret(file)
265
+ tries = 0
266
+ loop do
267
+ return nil unless File.exist?(file)
268
+
269
+ text = read(file)
270
+ return text if text.length >= SHORTEST_SECRET
271
+ refuse_secret(file) if tries >= 20
272
+
273
+ tries += 1
274
+ sleep(0.1)
275
+ end
276
+ end
277
+
278
+ def refuse_secret(file)
279
+ raise RuntimeError, "Runlight: #{file} holds no whole secret. Delete it to have a new one made, which signs everyone out, or set RUNLIGHT_SECRET."
280
+ end
281
+
237
282
  def read(file, strip: true)
238
283
  text = File.file?(file) ? File.read(file) : ""
239
284
  strip ? text.strip : text
@@ -17,7 +17,7 @@ module Runlight
17
17
  class Standalone
18
18
  # The server's own pages, which answer as the server on every name it is reached at, a link domain too.
19
19
  SERVER_PATHS = ["/login", "/logout", "/setup", "/invite", "/healthz", "/auth.css", "/auth.js", "/api", "/mcp",
20
- "/s.js", "/pick.js", "/e", "/embed"].freeze
20
+ "/s.js", "/pick.js", "/pick-return", "/pick-return.js", "/e", "/embed"].freeze
21
21
 
22
22
  # The most names remembered as the server's own. The first ones stay and later ones are not learned, so
23
23
  # a server reached at more names than this needs RUNLIGHT_URL to keep the rest from becoming link domains.
@@ -135,8 +135,11 @@ module Runlight
135
135
  begin
136
136
  # A domain pointed at this server for short links answers at its root, with links one segment deep. The
137
137
  # server's own pages and its public address never answer as links, and "/" stays the dashboard for someone
138
- # signed in, so a link domain added on the dashboard's own name can always be removed again.
139
- linkable = path == Core::LINK_DOMAIN_CHECK ||
138
+ # signed in, so a link domain added on the dashboard's own name can always be removed again. /go/:slug is
139
+ # the server's own link path, which a link domain answers as a path with no link (a 404), as every adapter
140
+ # does.
141
+ own = request.method == "GET" && path.match?(%r{\A/go/[^/]+/?\z})
142
+ linkable = path == Core::LINK_DOMAIN_CHECK || own ||
140
143
  (path.match?(%r{\A/[^/]*\z}) && !SERVER_PATHS.include?(path) &&
141
144
  !(path == "/" && !@web.signed_in(request).nil?))
142
145
  if linkable && !(!@public_host.nil? && host_of(request) == @public_host)
@@ -146,9 +149,7 @@ module Runlight
146
149
  if path == "/healthz"
147
150
  return Http::Response.new("ok", status: 200, headers: { "content-type" => "text/plain", "cache-control" => "no-store" })
148
151
  end
149
- if request.method == "GET" && path.match?(%r{\A/go/[^/]+/?\z})
150
- return @runlight.link_handler.call(request, context)
151
- end
152
+ return @runlight.link_handler.call(request, context) if own
152
153
 
153
154
  # Everything else, the sign-in pages and People included, is the routes'.
154
155
  @routes.handle(request, context)
@@ -31,6 +31,16 @@ module Runlight
31
31
  # How long after a visit starts its events are looked for: far past any real visit.
32
32
  EVENT_TAIL_MS = 2 * 86_400_000
33
33
 
34
+ # What every table and index name starts with, unless a store is given another prefix in its place.
35
+ PREFIX = "rl_"
36
+
37
+ # Runlight's tables, after the prefix: the store's, and the sign-in accounts' two. An app dropping
38
+ # Runlight's tables drops these by name, never every table that starts with the prefix.
39
+ TABLES = %w[
40
+ meta sites salts sessions events links link_domains shares goals settings
41
+ reports tokens funnels rollup_days rollups users invites
42
+ ].freeze
43
+
34
44
  PIECE_MS = 86_400_000
35
45
 
36
46
  # Pageviews that can report engaged time: the tracker's, which carry a pageview id. Imported history has
@@ -28,14 +28,33 @@ module Runlight
28
28
  EVENT_TAIL_MS = Sql::EVENT_TAIL_MS
29
29
  MYSQL_COLLATION = Sql::MYSQL_COLLATION
30
30
 
31
- attr_reader :db
32
-
33
- def initialize(db)
31
+ PREFIX = Sql::PREFIX
32
+ TABLES = Sql::TABLES
33
+
34
+ attr_reader :db,
35
+ # What the store's table and index names start with: `rl_` unless it was given another.
36
+ :prefix
37
+
38
+ # `prefix` takes the place of `rl_` at the start of every table and index name, so two apps can each keep
39
+ # Runlight's tables in one database. A database that is already prefixed keeps its own.
40
+ def initialize(db, prefix = PREFIX)
41
+ if db.is_a?(Db::Prefixed)
42
+ prefix = db.prefix
43
+ elsif prefix != PREFIX
44
+ db = Db::Prefixed.new(db, prefix)
45
+ end
34
46
  @db = db
47
+ @prefix = prefix
35
48
  @ready = false
36
49
  @checked_all = false
37
50
  end
38
51
 
52
+ # The store's tables as named in the database, for an app removing Runlight: each one by name, since
53
+ # another app's tables may well start with the same letters.
54
+ def tables
55
+ TABLES.map { |table| "#{@prefix}#{table}" }
56
+ end
57
+
39
58
  # Creates the tables on first use. Safe to call any number of times.
40
59
  #
41
60
  # When the database already records the current schema version, that is taken as done, so a store opened
@@ -619,13 +638,37 @@ module Runlight
619
638
  value = setting(key)
620
639
  return nil if value.nil?
621
640
 
622
- sql = "DELETE FROM rl_settings WHERE \"key\" = ?"
623
- gone = if @db.dialect == "mysql" && @db.respond_to?(:affected)
624
- @db.affected(sql, [key])
625
- else
626
- @db.all("#{sql} RETURNING \"key\"", [key]).length
627
- end
628
- gone == 1 ? value : nil
641
+ setting_changed("DELETE FROM rl_settings WHERE \"key\" = ?", [key]) == 1 ? value : nil
642
+ end
643
+
644
+ # Writes a setting only if it is not there yet. Of two callers at once, only one gets true.
645
+ def add_setting(key, value)
646
+ # MySQL counts a row a duplicate key found as written, so it ignores the insert instead.
647
+ sql = if @db.dialect == "mysql"
648
+ "INSERT IGNORE INTO rl_settings (\"key\", value) VALUES (?, ?)"
649
+ else
650
+ Sql.upsert(@db.dialect, "rl_settings", ['"key"', "value"], ['"key"'], [])
651
+ end
652
+ setting_changed(sql, [key, value]) == 1
653
+ end
654
+
655
+ # Changes a setting only if it still holds `from`. Of two callers at once, only one gets true.
656
+ def swap_setting(key, from, to)
657
+ setting_changed("UPDATE rl_settings SET value = ? WHERE \"key\" = ? AND value = ?", [to, key, from]) == 1
658
+ end
659
+
660
+ # A setting, or the block's value written as it when there is none yet, such as a signing key made on first
661
+ # use. Of two callers at once, both get the value written first, so nothing signed with a key that lost is
662
+ # left invalid. An empty one counts as none.
663
+ def setting_or_add(key)
664
+ 5.times do
665
+ saved = setting(key)
666
+ return saved if !saved.nil? && saved != ""
667
+
668
+ value = yield
669
+ return value if saved.nil? ? add_setting(key, value) : swap_setting(key, saved, value)
670
+ end
671
+ raise "Runlight: could not read or write the setting #{key}"
629
672
  end
630
673
 
631
674
  def set_setting(key, value)
@@ -1302,6 +1345,13 @@ module Runlight
1302
1345
 
1303
1346
  private
1304
1347
 
1348
+ # How many settings a statement changed. MySQL has no RETURNING, so it asks the connection how many rows matched.
1349
+ def setting_changed(sql, params)
1350
+ return @db.affected(sql, params) if @db.dialect == "mysql" && @db.respond_to?(:affected)
1351
+
1352
+ @db.all("#{sql} RETURNING \"key\"", params).length
1353
+ end
1354
+
1305
1355
  # SQL for "a visit that started in one of these stretches".
1306
1356
  def within(rest)
1307
1357
  return { "sql" => "1 = 0", "params" => [] } if rest.empty?
@@ -1358,16 +1408,18 @@ module Runlight
1358
1408
  # A concurrent build that was stopped leaves its index unusable; it goes, and is built again below.
1359
1409
  broken = db.all(
1360
1410
  "SELECT c.relname AS name FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
1361
- WHERE NOT i.indisvalid AND c.relname LIKE 'rl\\_%' AND c.relnamespace = current_schema()::regnamespace",
1411
+ WHERE NOT i.indisvalid AND c.relname LIKE '#{@prefix.gsub("_", "\\\\_")}%' AND c.relnamespace = current_schema()::regnamespace",
1362
1412
  )
1363
- broken.each { |row| db.run("DROP INDEX IF EXISTS \"#{Sql.string(row["name"]).delete('"')}\"") }
1413
+ # The name comes back as the database has it, so it goes back under rl_ before the prefix is put on
1414
+ # again, which would otherwise be put on twice for a prefix that itself starts rl_, such as rl_wp_.
1415
+ broken.each { |row| db.run("DROP INDEX IF EXISTS \"#{unnamed(Sql.string(row["name"])).delete('"')}\"") }
1364
1416
  end
1365
1417
  statements.each do |statement|
1366
1418
  index = statement.match(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS (\w+) ON (\w+)/)
1367
1419
  if db.dialect == "mysql" && index
1368
1420
  # MySQL has no CREATE INDEX IF NOT EXISTS, so it is looked for first.
1369
1421
  unique, name, table = index.captures
1370
- there = db.all("SELECT 1 AS there FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1", [table, name])
1422
+ there = db.all("SELECT 1 AS there FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1", [named(table), named(name)])
1371
1423
  db.run(statement.sub(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS/, "CREATE #{unique}INDEX")) if there.empty?
1372
1424
  else
1373
1425
  db.run(postgres ? statement.sub(/\ACREATE (UNIQUE )?INDEX IF NOT EXISTS/) { "CREATE #{Regexp.last_match(1)}INDEX CONCURRENTLY IF NOT EXISTS" } : statement)
@@ -1390,6 +1442,16 @@ module Runlight
1390
1442
  db.run(Sql.upsert(db.dialect, "rl_meta", ['"key"', "value"], ['"key"'], ["value"]), ["schema", Sql::SCHEMA_VERSION.to_s])
1391
1443
  end
1392
1444
 
1445
+ # A name as the database has it, under the store's prefix, as Runlight's SQL writes it, under rl_.
1446
+ def unnamed(name)
1447
+ name.start_with?(@prefix) ? "#{PREFIX}#{name[@prefix.length..]}" : name
1448
+ end
1449
+
1450
+ # A table or index name as the database has it, under the store's prefix.
1451
+ def named(name)
1452
+ name.start_with?(PREFIX) ? "#{@prefix}#{name[PREFIX.length..]}" : name
1453
+ end
1454
+
1393
1455
  # When a site's oldest row at or after `from` is (nil for no lower bound), or nil when there is none.
1394
1456
  def oldest(table, col, site, from)
1395
1457
  row = @db.all("SELECT MIN(#{col}) AS t FROM #{table} WHERE site = ?#{from.nil? ? "" : " AND #{col} >= ?"}", from.nil? ? [site] : [site, from])[0]
@@ -3,47 +3,51 @@
3
3
  module Runlight
4
4
  # The stores stores/sqlite.ts, stores/postgres.ts, and stores/mysql.ts make, over ActiveRecord. Tables are
5
5
  # prefixed `rl_`, so the database can be the app's own, and a database made by the TypeScript SDK opens here.
6
+ # Each takes `prefix:` in place of `rl_`: what every table and index name starts with, so two apps sharing
7
+ # one database each keep a set of their own, up to 32 letters, digits, and underscores, not starting with a
8
+ # digit.
6
9
  module Stores
7
10
  module_function
8
11
 
9
- # Runlight's tables in a SQLite file (or ":memory:"), in WAL mode, as better-sqlite3 opens it there.
10
- def sqlite(path)
11
- Store::SqlStore.new(Db::Connect.sqlite(path))
12
+ # Runlight's tables in a SQLite file (or ":memory:"), in WAL mode, as better-sqlite3 opens it there. A
13
+ # file it makes is for its owner and group alone (0660).
14
+ def sqlite(path, prefix: Store::Sql::PREFIX)
15
+ Store::SqlStore.new(Db::Connect.sqlite(path), prefix)
12
16
  end
13
17
 
14
18
  # Runlight's tables in Postgres, from a connection string. statement_timeout is the longest one statement
15
19
  # may run, in milliseconds (default 120000, 0 for no limit); schema is the search path.
16
- def postgres(url, statement_timeout: 120_000, schema: nil)
20
+ def postgres(url, statement_timeout: 120_000, schema: nil, prefix: Store::Sql::PREFIX)
17
21
  raise ArgumentError, "Runlight: postgres() needs a url" if url.to_s.empty?
18
22
 
19
- Store::SqlStore.new(Db::Connect.postgres(url, statement_timeout: statement_timeout, schema: schema))
23
+ Store::SqlStore.new(Db::Connect.postgres(url, statement_timeout: statement_timeout, schema: schema), prefix)
20
24
  end
21
25
 
22
- # Runlight's tables in MySQL 8.4 or MariaDB 11.4 and later, from a mysql:// or mariadb:// URL. Text is
26
+ # Runlight's tables in MySQL 8.0.20 or MariaDB 11.4 and later, from a mysql:// or mariadb:// URL. Text is
23
27
  # utf8mb4 with a binary collation, so it compares and sorts by code point, case and trailing spaces
24
28
  # included, as SQLite and Postgres do. statement_timeout is in milliseconds (default 120000, 0 for no
25
29
  # limit), which MySQL applies to reads only and MariaDB to every statement.
26
- def mysql(url, statement_timeout: 120_000)
30
+ def mysql(url, statement_timeout: 120_000, prefix: Store::Sql::PREFIX)
27
31
  raise ArgumentError, "Runlight: mysql() needs a url" if url.to_s.empty?
28
32
 
29
- Store::SqlStore.new(Db::Connect.mysql(url, statement_timeout: statement_timeout))
33
+ Store::SqlStore.new(Db::Connect.mysql(url, statement_timeout: statement_timeout), prefix)
30
34
  end
31
35
 
32
36
  # The store a DATABASE_URL names, as the standalone server picks one: postgres:// or postgresql:// for
33
37
  # Postgres, mysql:// or mariadb:// for MySQL, and sqlite: or file: followed by a path for SQLite.
34
- def url(database_url)
35
- Store::SqlStore.new(Db::Connect.url(database_url))
38
+ def url(database_url, prefix: Store::Sql::PREFIX)
39
+ Store::SqlStore.new(Db::Connect.url(database_url), prefix)
36
40
  end
37
41
 
38
42
  # Runlight's tables in the database an ActiveRecord class connects to: the app's own by default. Writes
39
43
  # join a transaction the app has open.
40
- def active_record(connection_class = ::ActiveRecord::Base)
41
- Store::SqlStore.new(Db::Database.new(connection_class))
44
+ def active_record(connection_class = ::ActiveRecord::Base, prefix: Store::Sql::PREFIX)
45
+ Store::SqlStore.new(Db::Database.new(connection_class), prefix)
42
46
  end
43
47
 
44
- # A store over any Db::Database.
45
- def from_db(db)
46
- Store::SqlStore.new(db)
48
+ # A store over any Db::Database, with its tables under `prefix` in place of `rl_` when one is given.
49
+ def from_db(db, prefix: Store::Sql::PREFIX)
50
+ Store::SqlStore.new(db, prefix)
47
51
  end
48
52
  end
49
53
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runlight
4
+ # Another process held the lock for creating tables past Db::Database::LOCK_WAIT_MS, such as one building an
5
+ # index on a big table after an upgrade. The request that waited fails with this, rather than waiting for good.
6
+ class TablesBusyError < RuntimeError
7
+ def initialize(waited = Db::Database::LOCK_WAIT_MS)
8
+ super("Runlight: another process is creating or upgrading the tables, and had not finished after #{(waited / 1000.0).round} seconds. Try again in a minute.")
9
+ end
10
+ end
11
+ end
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Runlight
6
6
  # The gem's own version, which the release keeps equal to the npm package's.
7
- GEM_VERSION = "0.1.1"
7
+ GEM_VERSION = "0.1.3"
8
8
 
9
9
  # The SDK's version and the HTTP API's, read from assets/build.json, which
10
10
  # scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
data/lib/runlight.rb CHANGED
@@ -38,11 +38,13 @@ module Runlight
38
38
  autoload :Mcp, "runlight/mcp"
39
39
  autoload :McpError, "runlight/mcp_error"
40
40
  autoload :Messages, "runlight/messages"
41
+ autoload :MetadataAddressError, "runlight/metadata_address_error"
41
42
  autoload :Mmdb, "runlight/mmdb"
42
43
  autoload :OAuth, "runlight/oauth"
43
44
  autoload :Options, "runlight/options"
44
45
  autoload :Payload, "runlight/payload"
45
46
  autoload :PrivateAddressError, "runlight/private_address_error"
47
+ autoload :RedirectRefusedError, "runlight/redirect_refused_error"
46
48
  autoload :Query, "runlight/query"
47
49
  autoload :RackApp, "runlight/rack_app"
48
50
  autoload :RateLimit, "runlight/rate_limit"
@@ -51,6 +53,7 @@ module Runlight
51
53
  autoload :Safefetch, "runlight/safefetch"
52
54
  autoload :SettingsError, "runlight/settings_error"
53
55
  autoload :Sources, "runlight/sources"
56
+ autoload :TablesBusyError, "runlight/tables_busy_error"
54
57
  autoload :Ua, "runlight/ua"
55
58
  autoload :Zip, "runlight/zip"
56
59
 
@@ -73,6 +76,7 @@ module Runlight
73
76
  autoload :Connect, "runlight/db/connect"
74
77
  autoload :Database, "runlight/db/db"
75
78
  autoload :Pools, "runlight/db/connect"
79
+ autoload :Prefixed, "runlight/db/prefixed"
76
80
  end
77
81
 
78
82
  module Http
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: runlight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-11 00:00:00.000000000 Z
11
+ date: 2026-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -46,6 +46,7 @@ files:
46
46
  - assets/dashboard.js
47
47
  - assets/intl.json
48
48
  - assets/locales.json
49
+ - assets/pick-return.js
49
50
  - assets/picker.js
50
51
  - assets/tracker.js
51
52
  - assets/world.json
@@ -71,6 +72,7 @@ files:
71
72
  - lib/runlight/dates.rb
72
73
  - lib/runlight/db/connect.rb
73
74
  - lib/runlight/db/db.rb
75
+ - lib/runlight/db/prefixed.rb
74
76
  - lib/runlight/env.rb
75
77
  - lib/runlight/funnel_error.rb
76
78
  - lib/runlight/funnels.rb
@@ -114,6 +116,7 @@ files:
114
116
  - lib/runlight/mcp.rb
115
117
  - lib/runlight/mcp_error.rb
116
118
  - lib/runlight/messages.rb
119
+ - lib/runlight/metadata_address_error.rb
117
120
  - lib/runlight/mmdb.rb
118
121
  - lib/runlight/oauth.rb
119
122
  - lib/runlight/options.rb
@@ -123,6 +126,7 @@ files:
123
126
  - lib/runlight/rack_app.rb
124
127
  - lib/runlight/rails.rb
125
128
  - lib/runlight/rate_limit.rb
129
+ - lib/runlight/redirect_refused_error.rb
126
130
  - lib/runlight/reports.rb
127
131
  - lib/runlight/routes.rb
128
132
  - lib/runlight/safefetch.rb
@@ -137,6 +141,7 @@ files:
137
141
  - lib/runlight/store/sql.rb
138
142
  - lib/runlight/store/sql_store.rb
139
143
  - lib/runlight/stores.rb
144
+ - lib/runlight/tables_busy_error.rb
140
145
  - lib/runlight/ua.rb
141
146
  - lib/runlight/undefined.rb
142
147
  - lib/runlight/version.rb