tina4ruby 3.13.94 → 3.13.97

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/session.rb CHANGED
@@ -8,11 +8,47 @@ module Tina4
8
8
  secret: nil,
9
9
  max_age: 3600,
10
10
  handler: :file,
11
- handler_options: {}
11
+ handler_options: {},
12
+ # Opt-in, and OFF for every direct caller. See the construction guard in
13
+ # #initialize: only the live REQUEST PATH degrades when the storage
14
+ # handler cannot be built; boot, the CLI, a spec and app code that call
15
+ # Session.new themselves still get the loud raise they rely on.
16
+ degrade_on_backend_failure: false
12
17
  }.freeze
13
18
 
14
19
  attr_reader :id, :data
15
20
 
21
+ # A session id is OPAQUE — an unguessable lookup token and nothing else. It
22
+ # is never a filename, a path, a SQL fragment or a Redis key fragment, so the
23
+ # only characters it may contain are the ones every backend treats as inert.
24
+ #
25
+ # The alphabet is the RFC 4648 base64url set, which is exactly what all four
26
+ # frameworks already mint: Ruby SecureRandom.hex(32), Python
27
+ # secrets.token_urlsafe(32), PHP/Node hex(16). Validation is therefore
28
+ # non-breaking for every id the family has ever issued, while rejecting the
29
+ # "." and "/" that turn a cookie into a path traversal.
30
+ #
31
+ # The constraint is the ALPHABET, not the length. There is deliberately NO
32
+ # entropy floor: unguessability comes from the framework MINTING the id
33
+ # (SecureRandom.hex(32)), never from inspecting one an app passed on purpose,
34
+ # so a floor would close no attack while breaking trusted callers that manage
35
+ # their own short programmatic ids (start("my-session-id")). An
36
+ # attacker-supplied id is stopped by strict mode (see #adopt_or_mint), not by
37
+ # its length. The 128-character ceiling just bounds what can be pushed
38
+ # through a backend key.
39
+ #
40
+ # \A and \z, NEVER ^ and $: Ruby's ^/$ match LINE boundaries, so a "^...$"
41
+ # anchor would accept "legitimate_looking_id\n../../etc/passwd".
42
+ SESSION_ID_PATTERN = /\A[A-Za-z0-9_-]{1,128}\z/
43
+
44
+ # True when session_id is a well-formed opaque session identifier.
45
+ #
46
+ # Callers pass UNTRUSTED input here (the session cookie is attacker-chosen),
47
+ # so anything that is not a String of the opaque alphabet is rejected.
48
+ def self.valid_session_id?(session_id)
49
+ session_id.is_a?(String) && SESSION_ID_PATTERN.match?(session_id)
50
+ end
51
+
16
52
  # The session cookie name — the SINGLE source of truth shared by the WRITE
17
53
  # side (#cookie_header) and the READ side (#extract_session_id AND RackApp's
18
54
  # incoming-cookie parse), so a cookie written under a renamed name is read
@@ -49,6 +85,13 @@ module Tina4
49
85
  ttl_env = ENV["TINA4_SESSION_TTL"]
50
86
  @options[:max_age] = Integer(ttl_env) if ttl_env && !ttl_env.strip.empty?
51
87
  end
88
+ # The BACKEND lifetime, resolved once and forwarded to handler#write on
89
+ # every save (parity with Python's Session._ttl, which flows the same way).
90
+ # #save used to call safe_write(@id, @data) with NO ttl, so the cookie said
91
+ # Max-Age=900 while the stored record lived for the handler's own default -
92
+ # a silent disagreement between what the browser was told and what the
93
+ # store actually did. One resolver, both directions.
94
+ @ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
52
95
  # TINA4_SESSION_BACKEND — selects the storage handler unless the caller
53
96
  # explicitly passed :handler (same precedence as :cookie_name above; an
54
97
  # explicit option always wins over the environment). Without this the
@@ -66,10 +109,47 @@ module Tina4
66
109
  # session cookie for an encrypted request must never be sent in the clear.
67
110
  # Uses the SAME detector as Request#url so the two never disagree (issue #31).
68
111
  @request_secure = Tina4::Request.secure_scheme?(env || {})
69
- @handler = create_handler
70
- @id = extract_session_id(env) || SecureRandom.hex(32)
71
- @data = load_session
72
- @modified = false
112
+ # LOG LOUD, THEN DEGRADE (ADR-0021) - for handler CONSTRUCTION too.
113
+ #
114
+ # The read/write/destroy/gc policy further down has always been right, but
115
+ # it sat BELOW this line: create_handler ran bare. A handler whose
116
+ # constructor touches the network - the database backend opens its
117
+ # connection and issues DDL in #initialize - raised straight out of
118
+ # Session.new, out of Request#session, and into RackApp's 500 handler, so
119
+ # an unreachable backend took the whole REQUEST down instead of degrading
120
+ # it, and TINA4_SESSION_STRICT was INERT because the non-strict path
121
+ # already produced the identical 500.
122
+ #
123
+ # WHO DEGRADES, AND WHO STILL RAISES. Only the caller that opts in, which
124
+ # is the live request path (Request#session, RackApp.enforce_route_auth).
125
+ # Every other caller keeps the loud raise, deliberately: an unknown
126
+ # TINA4_SESSION_BACKEND is a CONFIGURATION error, not an outage, and the
127
+ # owner decision of 2026-07-31 (session_backend_validation_spec.rb, all
128
+ # four frameworks) is that it must fail fast where a human can fix it
129
+ # rather than serve on the wrong storage. This is the same split the
130
+ # Python master makes: its Session raises, and core/server.py's request
131
+ # path is what logs and degrades.
132
+ #
133
+ # A DEGRADED SESSION IS AN IN-MEMORY-ONLY SESSION: no handler, so nothing
134
+ # is read from or written to any store. The route still receives a working
135
+ # Session object - Ruby cannot hand back Python's `request.session = None`
136
+ # without turning every `session[...]` into a NoMethodError, which would
137
+ # 500 the very request this is saving - so reads yield an empty session
138
+ # and #save returns false, which is exactly the contract. The failure is
139
+ # logged ONCE, here, where it happened; see #degraded? for why not again.
140
+ @handler = nil
141
+ begin
142
+ @handler = create_handler
143
+ rescue StandardError => e
144
+ raise unless @options[:degrade_on_backend_failure]
145
+
146
+ log_backend_error("handler construction", e)
147
+ raise if @strict
148
+ end
149
+ # The cookie is the live server's session-id source and is fully
150
+ # attacker-controlled, so it goes through the same strict-mode funnel as
151
+ # an explicit #start.
152
+ adopt_or_mint(extract_session_id(env))
73
153
  end
74
154
 
75
155
  def [](key)
@@ -98,9 +178,13 @@ module Tina4
98
178
  # Persist the session if dirty. On a backend write failure the error is
99
179
  # logged and false is returned — the @modified (dirty) flag is RETAINED so
100
180
  # a later save can retry. Returns true on a successful (or no-op) write.
181
+ #
182
+ # A cleared id (@id nil, e.g. after #destroy) is a no-op: there is nothing
183
+ # to persist, and a write would re-create the just-destroyed record. Mirrors
184
+ # the Python master's `if self._session_id and self._dirty`.
101
185
  def save
102
- return true unless @modified
103
- if safe_write(@id, @data)
186
+ return true unless @id && @modified
187
+ if safe_write(@id, @data, @ttl)
104
188
  @modified = false
105
189
  true
106
190
  else
@@ -108,16 +192,36 @@ module Tina4
108
192
  end
109
193
  end
110
194
 
111
- # Destroy the current session. Should be called right after login or any
112
- # privilege change to defend against session fixation (see #regenerate).
195
+ # Destroy the current session. ENDS the session: the stored record is
196
+ # removed and the id is CLEARED (@id = nil), so a later set()+save() with no
197
+ # new #start has no id to persist under and cannot RESURRECT the just-
198
+ # destroyed record. Mirrors the Python master (nulls _session_id) and Node
199
+ # (nulls sessionId). A fresh session needs a new #start, which mints a new id.
113
200
  def destroy
114
- safe_destroy(@id)
201
+ safe_destroy(@id) if @id
202
+ @id = nil
115
203
  @data = {}
204
+ @modified = false
116
205
  end
117
206
 
118
- # Get a session value with optional default
207
+ # Get a session value with optional default.
208
+ #
209
+ # The default is returned for an ABSENT key, never for a stored FALSE. This
210
+ # was `@data[key.to_s] || default`, which handed back the caller's default
211
+ # for any falsy stored value — so a feature flag stored as false read back
212
+ # as the caller's `true` default. Python (`dict.get`), PHP (`??`) and Node
213
+ # (`??`) all return the stored false, so Ruby was the 1-of-4 outlier.
214
+ #
215
+ # Deliberately the `nil?` form and NOT `@data.key?(k) ? @data[k] : default`:
216
+ # both fix the false case, but the key? form would ALSO flip a stored nil
217
+ # from the default to nil. Ruby currently agrees with PHP and Node there
218
+ # (stored nil -> default) and only Python disagrees (stored None -> None),
219
+ # so changing it is a cross-framework decision, not a side effect of this
220
+ # fix. This form is exactly PHP's `??` and Node's `??`. Same idiom as
221
+ # #get_flash below.
119
222
  def get(key, default = nil)
120
- @data[key.to_s] || default
223
+ value = @data[key.to_s]
224
+ value.nil? ? default : value
121
225
  end
122
226
 
123
227
  # Set a session value
@@ -171,18 +275,15 @@ module Tina4
171
275
  @id
172
276
  end
173
277
 
174
- # Start or resume a session. If session_id is given, load that session;
175
- # otherwise generate a new ID. Returns the session ID string.
278
+ # Start or resume a session. Returns the session ID string.
279
+ #
280
+ # session_id is UNTRUSTED, so it goes through #adopt_or_mint: it is resumed
281
+ # only when it is a well-formed opaque id AND one the backend already holds a
282
+ # session under (strict mode). Otherwise a genuinely NEW session is started
283
+ # under a fresh SecureRandom.hex(32). A session already in flight keeps both
284
+ # its id and its data.
176
285
  def start(session_id = nil)
177
- if session_id
178
- @id = session_id
179
- @data = load_session
180
- else
181
- @id = SecureRandom.hex(32)
182
- @data = {}
183
- end
184
- @modified = false
185
- @id
286
+ adopt_or_mint(session_id)
186
287
  end
187
288
 
188
289
  # Returns the current session ID string.
@@ -238,6 +339,9 @@ module Tina4
238
339
 
239
340
  private
240
341
 
342
+ # The session id carried on the incoming cookie, or nil. A pure parser —
343
+ # every caller funnels through #adopt_or_mint, which is where the value is
344
+ # judged.
241
345
  def extract_session_id(env)
242
346
  cookie_str = env["HTTP_COOKIE"] || ""
243
347
  cookie_str.split(";").each do |pair|
@@ -247,8 +351,58 @@ module Tina4
247
351
  nil
248
352
  end
249
353
 
250
- def load_session
251
- safe_read(@id)
354
+ # Adopt session_id, or mint a fresh one — the SINGLE decision both entry
355
+ # points (the constructor's cookie path and #start) go through, so neither
356
+ # can drift from the rule or skip it.
357
+ #
358
+ # STRICT SESSION MODE (OWASP; PHP's session.use_strict_mode=1). session_id is
359
+ # UNTRUSTED: it comes from the session cookie or a caller, both of which the
360
+ # client controls. It is adopted ONLY when it is BOTH
361
+ # (a) a well-formed opaque id (see valid_session_id?), and
362
+ # (b) an id the backend already holds a session under.
363
+ # Anything else — malformed, or well-formed but never issued — is DISCARDED
364
+ # and a fresh SecureRandom.hex(32) minted. Adopting either one is session
365
+ # fixation: an attacker plants a cookie, the victim logs in under it, and the
366
+ # attacker already holds the authenticated session id. A malformed id also
367
+ # used to steer a filesystem path.
368
+ #
369
+ # Sets @id/@data/@modified and returns the resolved id.
370
+ def adopt_or_mint(session_id)
371
+ session_id = nil unless self.class.valid_session_id?(session_id)
372
+ data = session_id.nil? ? nil : existing_session_data(session_id)
373
+ if data.nil?
374
+ session_id = SecureRandom.hex(32)
375
+ data = {}
376
+ end
377
+ @id = session_id
378
+ @data = data
379
+ @modified = false
380
+ @id
381
+ end
382
+
383
+ # The stored data for session_id when the backend HOLDS a session under it,
384
+ # else nil — strict mode's signal to mint a fresh id rather than adopt one
385
+ # the client chose.
386
+ #
387
+ # nil AND empty both count as "no session": the handlers disagree (file,
388
+ # redis, valkey, mongo and database return nil for a missing session, while
389
+ # memcached returns {}), and strict mode must not silently no-op on one
390
+ # backend. An empty STORED session cannot arise anyway — #save is a no-op
391
+ # until something is actually written.
392
+ #
393
+ # A backend FAILURE is deliberately NOT "no session": it logs and returns {},
394
+ # so the id is still adopted. Reading an outage as "unknown" would rotate
395
+ # every id and log the entire userbase out on a single Redis blip — the
396
+ # opposite of the log-loud + degrade policy below.
397
+ def existing_session_data(session_id)
398
+ return nil if degraded?
399
+
400
+ data = @handler.read(session_id)
401
+ data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data
402
+ rescue StandardError => e
403
+ log_backend_error("read", e)
404
+ raise if @strict
405
+ {}
252
406
  end
253
407
 
254
408
  # ── Backend-failure policy (parity with Python's Session boundary) ──
@@ -263,7 +417,19 @@ module Tina4
263
417
  # raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
264
418
  # re-raises instead of degrading.
265
419
 
420
+ # True when handler construction failed and this session is in-memory only
421
+ # (see #initialize). Every store operation below short-circuits to its
422
+ # degraded answer WITHOUT logging: the outage was already logged once, at
423
+ # the point it actually happened, and re-logging the same fact on every
424
+ # read/write would multiply one dead backend into a line per operation and
425
+ # bury it - the same blindness this whole policy exists to cure.
426
+ def degraded?
427
+ @handler.nil?
428
+ end
429
+
266
430
  def safe_read(session_id)
431
+ return {} if degraded?
432
+
267
433
  existing = @handler.read(session_id)
268
434
  existing || {}
269
435
  rescue StandardError => e
@@ -273,6 +439,8 @@ module Tina4
273
439
  end
274
440
 
275
441
  def safe_write(session_id, data, ttl = nil)
442
+ return false if degraded?
443
+
276
444
  if ttl
277
445
  @handler.write(session_id, data, ttl)
278
446
  else
@@ -286,6 +454,8 @@ module Tina4
286
454
  end
287
455
 
288
456
  def safe_destroy(session_id)
457
+ return false if degraded?
458
+
289
459
  @handler.destroy(session_id)
290
460
  true
291
461
  rescue StandardError => e
@@ -295,14 +465,48 @@ module Tina4
295
465
  end
296
466
 
297
467
  # Single source of the backend-failure log line. Names the operation and
298
- # the concrete handler class so ops can see WHICH backend failed.
468
+ # the concrete handler class so ops can see WHICH backend failed. When
469
+ # CONSTRUCTION is what failed there is no handler yet, so it falls back to
470
+ # the CONFIGURED backend name - which is the thing the operator has to fix
471
+ # ("redsi", "database"), and strictly more useful than "NilClass".
299
472
  def log_backend_error(operation, error)
300
- handler_class = @handler.class.name
473
+ handler_class = @handler ? @handler.class.name : @options[:handler].to_s
301
474
  Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
302
475
  rescue StandardError
303
476
  warn("Session #{operation} failed: #{error.message}")
304
477
  end
305
478
 
479
+ # Every accepted backend name, aliases included. Byte-identical membership in
480
+ # all four frameworks. Written once here so the case below and the error
481
+ # message can never disagree.
482
+ VALID_BACKENDS = %w[
483
+ file filesystem
484
+ redis
485
+ valkey
486
+ mongodb mongo
487
+ memcached memcache
488
+ database db
489
+ ].freeze
490
+
491
+ # Canonical name of each backend, for the error message. Listing every alias
492
+ # would make it longer without making it clearer.
493
+ CANONICAL_BACKENDS = %w[file redis valkey mongodb memcached database].freeze
494
+
495
+ # Reject a backend name that is not a known backend.
496
+ #
497
+ # Never sees a blank name: create_handler normalises blank to "file" first,
498
+ # in one place. Blank must NOT be an error - an env var set to "" is a SET
499
+ # variable, so rejecting it would break every deployment that clears the var
500
+ # to fall back to the default.
501
+ def validate_backend!(name)
502
+ return if VALID_BACKENDS.include?(name)
503
+
504
+ raise ArgumentError,
505
+ "Unknown session backend \"#{name}\". " \
506
+ "Valid backends: #{CANONICAL_BACKENDS.join(', ')}. " \
507
+ "Leave TINA4_SESSION_BACKEND unset for the file default."
508
+ end
509
+
306
510
  # The configured TINA4_SESSION_BACKEND name, or nil when unset/blank (so the
307
511
  # caller keeps the DEFAULT_OPTIONS handler). The value is normalised at
308
512
  # dispatch in #create_handler, not here.
@@ -313,13 +517,23 @@ module Tina4
313
517
 
314
518
  # Build the storage handler for the resolved backend name.
315
519
  #
316
- # The accepted names and the aliases mirror Python's
520
+ # The accepted names - and the aliases - mirror Python's
317
521
  # Session._resolve_handler exactly: file|filesystem, redis, valkey,
318
- # mongodb|mongo, database|db. The name is normalised (downcase + strip) so
319
- # "Redis" / " mongodb " from a .env line resolve, and an UNKNOWN value falls
320
- # back to the file handler SILENTLY (never raises) — same as Python.
522
+ # mongodb|mongo, memcached|memcache, database|db. The name is normalised
523
+ # (downcase + strip) so "Redis" / " mongodb " from a .env line resolve.
524
+ #
525
+ # An UNKNOWN value RAISES. It used to fall back to the file handler
526
+ # silently, and the comment here described that as correct parity with
527
+ # Python - it was, and both were wrong. A typo in TINA4_SESSION_BACKEND
528
+ # ("redsi") produced a running app writing sessions to local disk while the
529
+ # operator believed they were in Redis: nothing logged, nothing failed, and
530
+ # the symptom surfaced later as users being logged out whenever a request
531
+ # landed on another instance.
321
532
  def create_handler
322
- case @options[:handler].to_s.downcase.strip.to_sym
533
+ name = @options[:handler].to_s.downcase.strip
534
+ name = "file" if name.empty?
535
+ validate_backend!(name)
536
+ case name.to_sym
323
537
  when :file, :filesystem
324
538
  Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
325
539
  when :redis
@@ -328,6 +542,8 @@ module Tina4
328
542
  Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
329
543
  when :valkey
330
544
  Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
545
+ when :memcached, :memcache
546
+ Tina4::SessionHandlers::MemcachedHandler.new(@options[:handler_options])
331
547
  when :database, :db
332
548
  # Parity with Python: the database backend "uses whatever DB is
333
549
  # connected", so reuse the single ORM resolver (named binding → global
@@ -339,7 +555,14 @@ module Tina4
339
555
  { db: Tina4::ORM.db }.merge(@options[:handler_options] || {})
340
556
  )
341
557
  else
342
- Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
558
+ # Unreachable for a user's typo - validate_backend! already rejected it.
559
+ # Only a name that IS in VALID_BACKENDS but has no branch above can land
560
+ # here, which is a bug in this method rather than a configuration error,
561
+ # so it must not be swallowed into a file handler either.
562
+ raise ArgumentError,
563
+ "Session backend #{name.inspect} is listed in VALID_BACKENDS but " \
564
+ "has no handler branch. This is a framework bug, not a " \
565
+ "configuration error."
343
566
  end
344
567
  end
345
568
  end
@@ -7,22 +7,120 @@ module Tina4
7
7
  class DatabaseHandler
8
8
  TABLE_NAME = "tina4_session"
9
9
 
10
- CREATE_TABLE_SQL = <<~SQL
11
- CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
12
- session_id VARCHAR(255) PRIMARY KEY,
13
- data TEXT NOT NULL,
14
- expires_at REAL NOT NULL
15
- )
16
- SQL
10
+ # CREATE TABLE per engine. This is the ONLY genuinely per-engine SQL in
11
+ # this file - every other statement is written once with `?` placeholders
12
+ # and rewritten for the driver by the Database layer.
13
+ #
14
+ # The COLUMNS are identical on every engine (session_id, data,
15
+ # expires_at) because the table is a CROSS-FRAMEWORK CONTRACT: a
16
+ # tina4_session table written by tina4-python must be readable by
17
+ # tina4-ruby. Only the type spellings and the "create it only if absent"
18
+ # idiom differ. Ported from the Node reference under ADR-0004
19
+ # (tina4-nodejs/packages/core/src/sessionHandlers/databaseHandler.ts).
20
+ #
21
+ # WHAT WAS WRONG. This used to be ONE generic string for every engine,
22
+ # opening with CREATE TABLE IF NOT EXISTS. That clause is not T-SQL, so
23
+ # the whole statement was a SYNTAX ERROR on SQL Server and the database
24
+ # session backend did not work on MSSQL AT ALL - the identical defect
25
+ # measured and fixed in PHP. Firebird rejected the same string twice
26
+ # over: it has no IF NOT EXISTS clause AND no TEXT type.
27
+ CREATE_TABLE_SQL = {
28
+ # Behaviour-identical to the generic DDL this replaces, so an EXISTING
29
+ # deployment is untouched. IF NOT EXISTS makes the statement a no-op
30
+ # against a table that is already there, so a live tina4_session keeps
31
+ # exactly the columns it was created with; and on a FRESH database the
32
+ # storage classes are the same ones the old spelling produced, because
33
+ # SQLite resolves VARCHAR(255) to TEXT affinity and DOUBLE PRECISION to
34
+ # REAL affinity. Only the declared type string in sqlite_master
35
+ # changes, and nothing reads that.
36
+ "sqlite" => <<~SQL,
37
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
38
+ session_id TEXT PRIMARY KEY,
39
+ data TEXT NOT NULL,
40
+ expires_at REAL NOT NULL
41
+ )
42
+ SQL
43
+ "postgres" => <<~SQL,
44
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
45
+ session_id VARCHAR(255) PRIMARY KEY,
46
+ data TEXT NOT NULL,
47
+ expires_at DOUBLE PRECISION NOT NULL
48
+ )
49
+ SQL
50
+ "mysql" => <<~SQL,
51
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
52
+ session_id VARCHAR(255) PRIMARY KEY,
53
+ data TEXT NOT NULL,
54
+ expires_at DOUBLE NOT NULL
55
+ )
56
+ SQL
57
+ # T-SQL has no CREATE TABLE IF NOT EXISTS. Node guards this statement
58
+ # with `IF OBJECT_ID(N'tina4_session', N'U') IS NULL`; that guard is
59
+ # deliberately NOT carried over, because it is CHECK-THEN-ACT and has a
60
+ # window - two connections can both see NULL and both CREATE, and the
61
+ # loser gets `Msg 2714: There is already an object named
62
+ # 'tina4_session'`, measured on live SQL Server. This takes Node's
63
+ # TYPES and pairs them with PHP's RESCUE in ensure_table below, which
64
+ # is strictly better than either alone: Node's types make the statement
65
+ # legal on this engine, and PHP's rescue closes the window Node's
66
+ # catalog check leaves open.
67
+ "mssql" => <<~SQL,
68
+ CREATE TABLE #{TABLE_NAME} (
69
+ session_id NVARCHAR(255) NOT NULL PRIMARY KEY,
70
+ data NVARCHAR(MAX) NOT NULL,
71
+ expires_at FLOAT NOT NULL
72
+ )
73
+ SQL
74
+ # Firebird has neither IF NOT EXISTS nor a TEXT type, so the catalog
75
+ # check goes in an EXECUTE BLOCK and the payload is a VARCHAR. That
76
+ # caps a session payload at 8191 characters ON THIS ENGINE ALONE, which
77
+ # is the deliberate price of not using BLOB SUB_TYPE TEXT: a driver
78
+ # hands a blob back as a reader rather than a string, which the read
79
+ # path here would not understand.
80
+ #
81
+ # VERIFIED AT THE SQL LEVEL ONLY (Firebird 5.0.4, via isql, measured on
82
+ # the lab container): CREATE TABLE IF NOT EXISTS fails -104 "Token
83
+ # unknown ... NOT", a TEXT column fails -607 "Specified domain or
84
+ # source column TEXT does not exist", DOUBLE PRECISION is accepted, and
85
+ # this EXECUTE BLOCK creates the table and is idempotent on a second
86
+ # run. That idempotence is check-then-act inside one block, so it is
87
+ # NOT a race guard - a bare CREATE with the table present gives
88
+ # SQLSTATE 42S01, so the rescue below is required here exactly as on
89
+ # mssql.
90
+ "firebird" => <<~SQL
91
+ EXECUTE BLOCK AS BEGIN
92
+ IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = '#{TABLE_NAME.upcase}')) THEN
93
+ EXECUTE STATEMENT 'CREATE TABLE #{TABLE_NAME.upcase} (SESSION_ID VARCHAR(255) NOT NULL PRIMARY KEY, DATA VARCHAR(8191) NOT NULL, EXPIRES_AT DOUBLE PRECISION NOT NULL)';
94
+ END
95
+ SQL
96
+ }.freeze
17
97
 
98
+ # NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4).
99
+ # Both lines this constructor used to run were real traffic:
100
+ #
101
+ # Tina4::Database.new(...) - #initialize ends in `connect` for a
102
+ # single-connection database (database.rb:405), so the driver DIALS.
103
+ # MEASURED against a real counting TCP listener: 1 accepted connection.
104
+ # ensure_table - a CREATE TABLE IF NOT EXISTS, real DDL on
105
+ # that connection.
106
+ #
107
+ # And because the request path builds a Session per request, that ran on
108
+ # EVERY request. Both sat OUTSIDE the log-loud-and-degrade policy, so an
109
+ # unreachable database took the app down at construction instead of
110
+ # degrading per request as designed. Connection and table are now resolved
111
+ # on FIRST USE, inside that policy.
18
112
  def initialize(options = {})
19
- @ttl = options[:ttl] || 86400
20
- @db = options[:db] || Tina4::Database.new(ENV["TINA4_DATABASE_URL"])
21
- ensure_table
113
+ # TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
114
+ # 86400. 3600 matches Python (the master), PHP and Node.
115
+ @ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
116
+ @db_option = options[:db]
117
+ @db = nil
118
+ @table_ready = false
22
119
  end
23
120
 
24
121
  def read(session_id)
25
- row = @db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
122
+ ensure_table
123
+ row = db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
26
124
  return nil unless row
27
125
 
28
126
  expires_at = (row[:expires_at] || row["expires_at"]).to_f
@@ -36,36 +134,103 @@ module Tina4
36
134
  nil
37
135
  end
38
136
 
39
- def write(session_id, data)
40
- expires_at = @ttl > 0 ? Time.now.to_f + @ttl : 0.0
137
+ # Write session data. A per-call +ttl+ WINS over the handler default; 0 means
138
+ # never expires and is stored as the 0 that read guards out.
139
+ #
140
+ # @param session_id [String] the session id
141
+ # @param data [Hash] the payload to store
142
+ # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
143
+ def write(session_id, data, ttl = 0)
144
+ ensure_table
145
+ effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
146
+ expires_at = effective_ttl.positive? ? Time.now.to_f + effective_ttl : 0.0
41
147
  json_data = JSON.generate(data)
42
148
 
43
- existing = @db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
149
+ existing = db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
44
150
  if existing
45
- @db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
151
+ db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
46
152
  else
47
- @db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
153
+ db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
48
154
  end
49
155
  end
50
156
 
51
157
  def destroy(session_id)
52
- @db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
158
+ ensure_table
159
+ db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
53
160
  end
54
161
 
55
162
  def cleanup
56
- @db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
163
+ ensure_table
164
+ db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
57
165
  end
58
166
 
59
167
  # Garbage-collect expired sessions. Matches the Python interface.
60
168
  # @param max_age [Integer] maximum session age in seconds (unused — expiry is absolute)
61
169
  def gc(max_age)
62
- @db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
170
+ ensure_table
171
+ db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
63
172
  end
64
173
 
65
174
  private
66
175
 
176
+ # The database connection, resolved on FIRST USE. An explicit :db option
177
+ # still wins; only the env-derived fallback has to be built, and building
178
+ # it CONNECTS (see #initialize), which is why it cannot happen earlier.
179
+ def db
180
+ @db ||= (@db_option || Tina4::Database.new(ENV["TINA4_DATABASE_URL"]))
181
+ end
182
+
183
+ # The CREATE TABLE this connection's engine understands.
184
+ #
185
+ # Tina4::Database#driver_name is the public accessor and it already holds
186
+ # the ALIAS-NORMALISED engine key ("postgres", not "postgresql"), the same
187
+ # value used to pick the driver class, so nothing here re-parses the
188
+ # connection string. An injected :db is an application-supplied object and
189
+ # therefore a trust boundary: one that cannot name its engine falls back
190
+ # to the generic ANSI shape, which is byte-for-byte what every engine used
191
+ # to get, so an app that duck-types a database is no worse off than before.
192
+ def create_table_sql
193
+ engine = db.respond_to?(:driver_name) ? db.driver_name.to_s : ""
194
+ CREATE_TABLE_SQL.fetch(engine, CREATE_TABLE_SQL["postgres"])
195
+ end
196
+
197
+ # Create the session table once, on first use rather than at construction.
198
+ # The flag is set BEFORE the DDL so an unreachable database is not
199
+ # re-probed on every call - the same ordering the Python master uses for
200
+ # _table_ready.
201
+ #
202
+ # THE CONCURRENT FIRST-USE RACE. Two workers booting together both reach
203
+ # this line and both issue the CREATE; one of them loses. IF NOT EXISTS
204
+ # settles that ENGINE-SIDE on sqlite, postgres and mysql, but T-SQL has no
205
+ # such clause and Firebird has none either, so on those two the loser
206
+ # raises and would take down the subsystem that decides whether anyone is
207
+ # logged in.
208
+ #
209
+ # The rescue below is the guard, ported from PHP. It RE-CHECKS whether the
210
+ # table exists rather than parsing the error message, because every engine
211
+ # spells "already exists" differently ("There is already an object named",
212
+ # "already exists", SQLSTATE 42S01) and a string match would rot the first
213
+ # time an engine reworded itself or ran under another locale.
67
214
  def ensure_table
68
- @db.execute(CREATE_TABLE_SQL)
215
+ return if @table_ready
216
+
217
+ @table_ready = true
218
+ begin
219
+ db.execute(create_table_sql)
220
+ rescue StandardError
221
+ # A failed statement leaves PostgreSQL's transaction ABORTED, so
222
+ # without this the re-check would fail for the wrong reason and report
223
+ # a missing table that is right there. Best effort: an engine with no
224
+ # open transaction is entitled to object to being rolled back.
225
+ begin
226
+ db.rollback
227
+ rescue StandardError
228
+ nil
229
+ end
230
+ # Somebody else created it - that is the race, and it is a success.
231
+ # Anything else is a real failure and is re-raised untouched.
232
+ raise unless db.table_exists?(TABLE_NAME)
233
+ end
69
234
  end
70
235
  end
71
236
  end