tina4ruby 3.11.35 → 3.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7dfb1cf2a3f34468c680f80f120b935a59348b7bab428fa5ba8c738a2a9ddf85
4
- data.tar.gz: 7ceb6e23bc01350d5de21c77fd4d7902a2a314933a5290a6db3dac272060865a
3
+ metadata.gz: 271f281382fed50cadd4ca76a86ecdd5273f5b9f8e80ed721d147e75aa197704
4
+ data.tar.gz: 10b1b39f0dda79f7fe5af9b42a4903a2d2c4d93e3331b7f7b246a0e890b85ca6
5
5
  SHA512:
6
- metadata.gz: f27c8ed10bc2c080ed765060989dafdc1c05c04bb255ac7a093cda62b692965e940b81d46f9b8f170ef0e3afd9cee44ee63d82eadc09410c34ef29338f1192ec
7
- data.tar.gz: 694ef83820ce2021da6ae51013259590fd70374ac2170831d039af3cb9dfa3a30ab1bf7ff691c4c8bcdaadefdf7e5ca9901c30a8a537b69ade99c8aaa8cb1c12
6
+ metadata.gz: ca638ffaba48b33c56a138509991bc3beed2b32cbeb63d4705cefac22b90a63563c6dc24835c71d82eed52b330e46c47953d738d28f1daf9ae5b214ce86ce8ef
7
+ data.tar.gz: 86a81881255a911e474e662c1b069c50bbf7fc58b1d336d6c36c08c1f5f870a46ee50681879e1248c869317dcf3a80bbccd8694821c8a39e17d19874f413c09c
data/lib/tina4/auth.rb CHANGED
@@ -19,7 +19,7 @@ module Tina4
19
19
 
20
20
  # Returns true when SECRET env var is set and no RSA keys exist in .keys/
21
21
  def use_hmac?
22
- secret = ENV["SECRET"]
22
+ secret = ENV["TINA4_SECRET"]
23
23
  return false if secret.nil? || secret.empty?
24
24
 
25
25
  # If RSA keys already exist on disk, prefer RS256 for backward compat
@@ -29,7 +29,7 @@ module Tina4
29
29
  end
30
30
 
31
31
  def hmac_secret
32
- ENV["SECRET"]
32
+ ENV["TINA4_SECRET"]
33
33
  end
34
34
 
35
35
  # Base64url-encode without padding (JWT spec)
@@ -191,7 +191,7 @@ module Tina4
191
191
  token = Regexp.last_match(1)
192
192
 
193
193
  # API_KEY bypass — matches tina4_python behavior
194
- api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
194
+ api_key = ENV["TINA4_API_KEY"]
195
195
  if api_key && !api_key.empty? && token == api_key
196
196
  return { "api_key" => true }
197
197
  end
@@ -206,7 +206,7 @@ module Tina4
206
206
  end
207
207
 
208
208
  def validate_api_key(provided, expected: nil)
209
- expected ||= ENV["TINA4_API_KEY"] || ENV["API_KEY"]
209
+ expected ||= ENV["TINA4_API_KEY"]
210
210
  return false if expected.nil? || expected.empty?
211
211
  return false if provided.nil? || provided.empty?
212
212
  return false if provided.length != expected.length
@@ -230,7 +230,7 @@ module Tina4
230
230
  token = Regexp.last_match(1)
231
231
 
232
232
  # API_KEY bypass — matches tina4_python behavior
233
- api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
233
+ api_key = ENV["TINA4_API_KEY"]
234
234
  if api_key && !api_key.empty? && token == api_key
235
235
  env["tina4.auth"] = { "api_key" => true }
236
236
  return true
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Periodic background task registry.
5
+ #
6
+ # Matches Python's `tina4_python.core.server.background(fn, interval)` and
7
+ # PHP's `$app->background($callback, $interval)` — a callback that runs
8
+ # periodically alongside the server lifecycle.
9
+ #
10
+ # Ruby has no asyncio event loop, so each task runs in its own thread.
11
+ # The GIL keeps it cooperative-enough for the periodic work this is meant
12
+ # for (queue draining, health checks, simulators). Errors in the callback
13
+ # are caught and logged so they don't kill the thread.
14
+ module Background
15
+ class << self
16
+ # Register a periodic callback.
17
+ #
18
+ # @param callback [#call, nil] Object responding to `call` with no args.
19
+ # @param interval [Float] Seconds between invocations (default 1.0).
20
+ # @param block [Proc] Optional block (used if callback is nil).
21
+ # @return [Hash] The registered task descriptor.
22
+ def register(callback = nil, interval: 1.0, &block)
23
+ cb = callback || block
24
+ raise ArgumentError, "background requires a callback or block" if cb.nil?
25
+ raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
26
+
27
+ task = { callback: cb, interval: interval.to_f, thread: nil, running: false }
28
+ mutex.synchronize { tasks << task }
29
+ start_task(task)
30
+ task
31
+ end
32
+
33
+ # All registered task descriptors. Tests use this for introspection.
34
+ def tasks
35
+ @tasks ||= []
36
+ end
37
+
38
+ # Stop and join every running task. Called on graceful shutdown.
39
+ def stop_all(timeout: 2.0)
40
+ snapshot = mutex.synchronize { tasks.dup }
41
+ snapshot.each { |task| stop_task(task, timeout: timeout) }
42
+ mutex.synchronize { tasks.clear }
43
+ end
44
+
45
+ # Stop a single task. Used by tests that register, fire, then stop.
46
+ def stop_task(task, timeout: 2.0)
47
+ task[:running] = false
48
+ thread = task[:thread]
49
+ return unless thread
50
+
51
+ thread.join(timeout) || thread.kill
52
+ task[:thread] = nil
53
+ end
54
+
55
+ private
56
+
57
+ def mutex
58
+ @mutex ||= Mutex.new
59
+ end
60
+
61
+ def start_task(task)
62
+ task[:running] = true
63
+ task[:thread] = Thread.new do
64
+ while task[:running]
65
+ sleep task[:interval]
66
+ break unless task[:running]
67
+
68
+ begin
69
+ task[:callback].call
70
+ rescue => e
71
+ # Never let a callback error kill the thread — next interval still fires.
72
+ if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
73
+ Tina4::Log.error("background task error: #{e.class}: #{e.message}")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -43,4 +43,44 @@ module Tina4
43
43
  TEXT_PLAIN = "text/plain; charset=utf-8"
44
44
  TEXT_CSV = "text/csv"
45
45
  TEXT_XML = "text/xml"
46
+
47
+ # ── HTTP Reason Phrases (RFC 7231 / RFC 9110) ──
48
+ #
49
+ # Used to write a correct HTTP/1.1 status line wherever the framework
50
+ # emits one manually. Previously code paths that built the status line
51
+ # by hand wrote "HTTP/1.1 404 OK" regardless of code, which is
52
+ # malformed. ``Tina4.http_reason(status)`` always returns a non-empty
53
+ # phrase that matches the status family.
54
+ HTTP_REASON_PHRASES = {
55
+ 100 => "Continue", 101 => "Switching Protocols",
56
+ 200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content",
57
+ 206 => "Partial Content",
58
+ 301 => "Moved Permanently", 302 => "Found", 303 => "See Other",
59
+ 304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect",
60
+ 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden",
61
+ 404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable",
62
+ 409 => "Conflict", 410 => "Gone", 413 => "Content Too Large",
63
+ 415 => "Unsupported Media Type", 422 => "Unprocessable Content",
64
+ 429 => "Too Many Requests",
65
+ 500 => "Internal Server Error", 501 => "Not Implemented",
66
+ 502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout"
67
+ }.freeze
68
+
69
+ # Return the canonical HTTP reason phrase for ``status``.
70
+ #
71
+ # Falls back to a sensible label when an exotic status is used. Never
72
+ # returns an empty string — the HTTP/1.1 status line requires a phrase.
73
+ # Prefers Rack::Utils::HTTP_STATUS_CODES when Rack is available so the
74
+ # phrase tracks Rack's mapping, otherwise uses the local table above.
75
+ def self.http_reason(status)
76
+ code = status.to_i
77
+ if defined?(Rack::Utils::HTTP_STATUS_CODES)
78
+ phrase = Rack::Utils::HTTP_STATUS_CODES[code]
79
+ return phrase if phrase && !phrase.empty?
80
+ end
81
+ phrase = HTTP_REASON_PHRASES[code]
82
+ return phrase if phrase && !phrase.empty?
83
+ return "OK" if code >= 200 && code < 300
84
+ "Error"
85
+ end
46
86
  end
@@ -4,7 +4,7 @@ module Tina4
4
4
  # Lightweight dependency injection container.
5
5
  #
6
6
  # Tina4::Container.register(:mailer) { MailService.new } # transient — new instance each get
7
- # Tina4::Container.singleton(:db) { Database.new(ENV["DB_URL"]) } # singleton — memoised
7
+ # Tina4::Container.singleton(:db) { Database.new(ENV["TINA4_DATABASE_URL"]) } # singleton — memoised
8
8
  # Tina4::Container.register(:cache, RedisCacheInstance) # concrete instance (always same)
9
9
  # Tina4::Container.get(:db) # => Database instance
10
10
  #
@@ -91,24 +91,33 @@ module Tina4
91
91
 
92
92
  # Construct a Database from environment variables.
93
93
  # Returns nil if the named env var is not set.
94
- def self.from_env(env_key: "DATABASE_URL", pool: 0)
94
+ def self.from_env(env_key: "TINA4_DATABASE_URL", pool: 0)
95
95
  url = ENV[env_key]
96
96
  return nil if url.nil? || url.strip.empty?
97
97
 
98
98
  new(url,
99
- username: ENV["DATABASE_USERNAME"],
100
- password: ENV["DATABASE_PASSWORD"],
99
+ username: ENV["TINA4_DATABASE_USERNAME"],
100
+ password: ENV["TINA4_DATABASE_PASSWORD"],
101
101
  pool: pool)
102
102
  end
103
103
 
104
104
  def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
105
- @connection_string = connection_string || ENV["DATABASE_URL"]
106
- @username = username || ENV["DATABASE_USERNAME"]
107
- @password = password || ENV["DATABASE_PASSWORD"]
105
+ @connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
106
+ @username = username || ENV["TINA4_DATABASE_USERNAME"]
107
+ @password = password || ENV["TINA4_DATABASE_PASSWORD"]
108
108
  @driver_name = driver_name || detect_driver(@connection_string)
109
109
  @pool_size = pool # 0 = single connection, N>0 = N pooled connections
110
110
  @connected = false
111
111
 
112
+ # Per-instance thread-local key for the transaction adapter pin.
113
+ # Without this pin, every Database method call rotates to a different
114
+ # pooled connection. Inside a transaction this silently breaks atomicity:
115
+ # start_transaction begins on adapter A, executes autocommit on B/C, and
116
+ # commit/rollback land on D — a no-op. start_transaction sets the pin,
117
+ # commit/rollback clear it. While pinned, current_driver returns the same
118
+ # driver for every call so the whole transaction runs on one connection.
119
+ @tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
120
+
112
121
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
113
122
  @cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
114
123
  @cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
@@ -161,7 +170,14 @@ module Tina4
161
170
  end
162
171
 
163
172
  # Get the current driver — from pool (round-robin) or single connection.
173
+ #
174
+ # Inside a transaction, all calls must land on the SAME driver — otherwise
175
+ # start_transaction, execute, and commit each rotate to a different pooled
176
+ # connection and the transaction is meaningless. start_transaction pins
177
+ # the driver to the calling thread; commit/rollback release it.
164
178
  def current_driver
179
+ pinned = Thread.current[@tx_pin_key]
180
+ return pinned if pinned
165
181
  if @pool
166
182
  @pool.checkout
167
183
  else
@@ -355,27 +371,38 @@ module Tina4
355
371
 
356
372
  def transaction
357
373
  drv = current_driver
374
+ Thread.current[@tx_pin_key] = drv
358
375
  drv.begin_transaction
359
376
  yield self
360
377
  drv.commit
361
378
  rescue => e
362
- drv.rollback
379
+ drv.rollback if drv
363
380
  raise e
381
+ ensure
382
+ Thread.current[@tx_pin_key] = nil
364
383
  end
365
384
 
366
385
  # Begin a transaction without a block — matches PHP/Python/Node API.
386
+ # Pins the driver to this thread for the whole transaction so executes
387
+ # and the final commit/rollback all run on the same connection.
367
388
  def start_transaction
368
- current_driver.begin_transaction
389
+ drv = current_driver
390
+ Thread.current[@tx_pin_key] = drv
391
+ drv.begin_transaction
369
392
  end
370
393
 
371
- # Commit the current transaction matches PHP/Python/Node API.
394
+ # Commit the current transaction and release the driver pin.
372
395
  def commit
373
396
  current_driver.commit
397
+ ensure
398
+ Thread.current[@tx_pin_key] = nil
374
399
  end
375
400
 
376
- # Roll back the current transaction matches PHP/Python/Node API.
401
+ # Roll back the current transaction and release the driver pin.
377
402
  def rollback
378
403
  current_driver.rollback
404
+ ensure
405
+ Thread.current[@tx_pin_key] = nil
379
406
  end
380
407
 
381
408
  def tables