tina4ruby 3.13.93 → 3.13.96

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 (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. 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)
@@ -100,7 +180,7 @@ module Tina4
100
180
  # a later save can retry. Returns true on a successful (or no-op) write.
101
181
  def save
102
182
  return true unless @modified
103
- if safe_write(@id, @data)
183
+ if safe_write(@id, @data, @ttl)
104
184
  @modified = false
105
185
  true
106
186
  else
@@ -115,9 +195,24 @@ module Tina4
115
195
  @data = {}
116
196
  end
117
197
 
118
- # Get a session value with optional default
198
+ # Get a session value with optional default.
199
+ #
200
+ # The default is returned for an ABSENT key, never for a stored FALSE. This
201
+ # was `@data[key.to_s] || default`, which handed back the caller's default
202
+ # for any falsy stored value — so a feature flag stored as false read back
203
+ # as the caller's `true` default. Python (`dict.get`), PHP (`??`) and Node
204
+ # (`??`) all return the stored false, so Ruby was the 1-of-4 outlier.
205
+ #
206
+ # Deliberately the `nil?` form and NOT `@data.key?(k) ? @data[k] : default`:
207
+ # both fix the false case, but the key? form would ALSO flip a stored nil
208
+ # from the default to nil. Ruby currently agrees with PHP and Node there
209
+ # (stored nil -> default) and only Python disagrees (stored None -> None),
210
+ # so changing it is a cross-framework decision, not a side effect of this
211
+ # fix. This form is exactly PHP's `??` and Node's `??`. Same idiom as
212
+ # #get_flash below.
119
213
  def get(key, default = nil)
120
- @data[key.to_s] || default
214
+ value = @data[key.to_s]
215
+ value.nil? ? default : value
121
216
  end
122
217
 
123
218
  # Set a session value
@@ -171,18 +266,15 @@ module Tina4
171
266
  @id
172
267
  end
173
268
 
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.
269
+ # Start or resume a session. Returns the session ID string.
270
+ #
271
+ # session_id is UNTRUSTED, so it goes through #adopt_or_mint: it is resumed
272
+ # only when it is a well-formed opaque id AND one the backend already holds a
273
+ # session under (strict mode). Otherwise a genuinely NEW session is started
274
+ # under a fresh SecureRandom.hex(32). A session already in flight keeps both
275
+ # its id and its data.
176
276
  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
277
+ adopt_or_mint(session_id)
186
278
  end
187
279
 
188
280
  # Returns the current session ID string.
@@ -238,6 +330,9 @@ module Tina4
238
330
 
239
331
  private
240
332
 
333
+ # The session id carried on the incoming cookie, or nil. A pure parser —
334
+ # every caller funnels through #adopt_or_mint, which is where the value is
335
+ # judged.
241
336
  def extract_session_id(env)
242
337
  cookie_str = env["HTTP_COOKIE"] || ""
243
338
  cookie_str.split(";").each do |pair|
@@ -247,8 +342,58 @@ module Tina4
247
342
  nil
248
343
  end
249
344
 
250
- def load_session
251
- safe_read(@id)
345
+ # Adopt session_id, or mint a fresh one — the SINGLE decision both entry
346
+ # points (the constructor's cookie path and #start) go through, so neither
347
+ # can drift from the rule or skip it.
348
+ #
349
+ # STRICT SESSION MODE (OWASP; PHP's session.use_strict_mode=1). session_id is
350
+ # UNTRUSTED: it comes from the session cookie or a caller, both of which the
351
+ # client controls. It is adopted ONLY when it is BOTH
352
+ # (a) a well-formed opaque id (see valid_session_id?), and
353
+ # (b) an id the backend already holds a session under.
354
+ # Anything else — malformed, or well-formed but never issued — is DISCARDED
355
+ # and a fresh SecureRandom.hex(32) minted. Adopting either one is session
356
+ # fixation: an attacker plants a cookie, the victim logs in under it, and the
357
+ # attacker already holds the authenticated session id. A malformed id also
358
+ # used to steer a filesystem path.
359
+ #
360
+ # Sets @id/@data/@modified and returns the resolved id.
361
+ def adopt_or_mint(session_id)
362
+ session_id = nil unless self.class.valid_session_id?(session_id)
363
+ data = session_id.nil? ? nil : existing_session_data(session_id)
364
+ if data.nil?
365
+ session_id = SecureRandom.hex(32)
366
+ data = {}
367
+ end
368
+ @id = session_id
369
+ @data = data
370
+ @modified = false
371
+ @id
372
+ end
373
+
374
+ # The stored data for session_id when the backend HOLDS a session under it,
375
+ # else nil — strict mode's signal to mint a fresh id rather than adopt one
376
+ # the client chose.
377
+ #
378
+ # nil AND empty both count as "no session": the handlers disagree (file,
379
+ # redis, valkey, mongo and database return nil for a missing session, while
380
+ # memcached returns {}), and strict mode must not silently no-op on one
381
+ # backend. An empty STORED session cannot arise anyway — #save is a no-op
382
+ # until something is actually written.
383
+ #
384
+ # A backend FAILURE is deliberately NOT "no session": it logs and returns {},
385
+ # so the id is still adopted. Reading an outage as "unknown" would rotate
386
+ # every id and log the entire userbase out on a single Redis blip — the
387
+ # opposite of the log-loud + degrade policy below.
388
+ def existing_session_data(session_id)
389
+ return nil if degraded?
390
+
391
+ data = @handler.read(session_id)
392
+ data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data
393
+ rescue StandardError => e
394
+ log_backend_error("read", e)
395
+ raise if @strict
396
+ {}
252
397
  end
253
398
 
254
399
  # ── Backend-failure policy (parity with Python's Session boundary) ──
@@ -263,7 +408,19 @@ module Tina4
263
408
  # raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
264
409
  # re-raises instead of degrading.
265
410
 
411
+ # True when handler construction failed and this session is in-memory only
412
+ # (see #initialize). Every store operation below short-circuits to its
413
+ # degraded answer WITHOUT logging: the outage was already logged once, at
414
+ # the point it actually happened, and re-logging the same fact on every
415
+ # read/write would multiply one dead backend into a line per operation and
416
+ # bury it - the same blindness this whole policy exists to cure.
417
+ def degraded?
418
+ @handler.nil?
419
+ end
420
+
266
421
  def safe_read(session_id)
422
+ return {} if degraded?
423
+
267
424
  existing = @handler.read(session_id)
268
425
  existing || {}
269
426
  rescue StandardError => e
@@ -273,6 +430,8 @@ module Tina4
273
430
  end
274
431
 
275
432
  def safe_write(session_id, data, ttl = nil)
433
+ return false if degraded?
434
+
276
435
  if ttl
277
436
  @handler.write(session_id, data, ttl)
278
437
  else
@@ -286,6 +445,8 @@ module Tina4
286
445
  end
287
446
 
288
447
  def safe_destroy(session_id)
448
+ return false if degraded?
449
+
289
450
  @handler.destroy(session_id)
290
451
  true
291
452
  rescue StandardError => e
@@ -295,14 +456,48 @@ module Tina4
295
456
  end
296
457
 
297
458
  # Single source of the backend-failure log line. Names the operation and
298
- # the concrete handler class so ops can see WHICH backend failed.
459
+ # the concrete handler class so ops can see WHICH backend failed. When
460
+ # CONSTRUCTION is what failed there is no handler yet, so it falls back to
461
+ # the CONFIGURED backend name - which is the thing the operator has to fix
462
+ # ("redsi", "database"), and strictly more useful than "NilClass".
299
463
  def log_backend_error(operation, error)
300
- handler_class = @handler.class.name
464
+ handler_class = @handler ? @handler.class.name : @options[:handler].to_s
301
465
  Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
302
466
  rescue StandardError
303
467
  warn("Session #{operation} failed: #{error.message}")
304
468
  end
305
469
 
470
+ # Every accepted backend name, aliases included. Byte-identical membership in
471
+ # all four frameworks. Written once here so the case below and the error
472
+ # message can never disagree.
473
+ VALID_BACKENDS = %w[
474
+ file filesystem
475
+ redis
476
+ valkey
477
+ mongodb mongo
478
+ memcached memcache
479
+ database db
480
+ ].freeze
481
+
482
+ # Canonical name of each backend, for the error message. Listing every alias
483
+ # would make it longer without making it clearer.
484
+ CANONICAL_BACKENDS = %w[file redis valkey mongodb memcached database].freeze
485
+
486
+ # Reject a backend name that is not a known backend.
487
+ #
488
+ # Never sees a blank name: create_handler normalises blank to "file" first,
489
+ # in one place. Blank must NOT be an error - an env var set to "" is a SET
490
+ # variable, so rejecting it would break every deployment that clears the var
491
+ # to fall back to the default.
492
+ def validate_backend!(name)
493
+ return if VALID_BACKENDS.include?(name)
494
+
495
+ raise ArgumentError,
496
+ "Unknown session backend \"#{name}\". " \
497
+ "Valid backends: #{CANONICAL_BACKENDS.join(', ')}. " \
498
+ "Leave TINA4_SESSION_BACKEND unset for the file default."
499
+ end
500
+
306
501
  # The configured TINA4_SESSION_BACKEND name, or nil when unset/blank (so the
307
502
  # caller keeps the DEFAULT_OPTIONS handler). The value is normalised at
308
503
  # dispatch in #create_handler, not here.
@@ -313,13 +508,23 @@ module Tina4
313
508
 
314
509
  # Build the storage handler for the resolved backend name.
315
510
  #
316
- # The accepted names and the aliases mirror Python's
511
+ # The accepted names - and the aliases - mirror Python's
317
512
  # 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.
513
+ # mongodb|mongo, memcached|memcache, database|db. The name is normalised
514
+ # (downcase + strip) so "Redis" / " mongodb " from a .env line resolve.
515
+ #
516
+ # An UNKNOWN value RAISES. It used to fall back to the file handler
517
+ # silently, and the comment here described that as correct parity with
518
+ # Python - it was, and both were wrong. A typo in TINA4_SESSION_BACKEND
519
+ # ("redsi") produced a running app writing sessions to local disk while the
520
+ # operator believed they were in Redis: nothing logged, nothing failed, and
521
+ # the symptom surfaced later as users being logged out whenever a request
522
+ # landed on another instance.
321
523
  def create_handler
322
- case @options[:handler].to_s.downcase.strip.to_sym
524
+ name = @options[:handler].to_s.downcase.strip
525
+ name = "file" if name.empty?
526
+ validate_backend!(name)
527
+ case name.to_sym
323
528
  when :file, :filesystem
324
529
  Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
325
530
  when :redis
@@ -328,6 +533,8 @@ module Tina4
328
533
  Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
329
534
  when :valkey
330
535
  Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
536
+ when :memcached, :memcache
537
+ Tina4::SessionHandlers::MemcachedHandler.new(@options[:handler_options])
331
538
  when :database, :db
332
539
  # Parity with Python: the database backend "uses whatever DB is
333
540
  # connected", so reuse the single ORM resolver (named binding → global
@@ -339,7 +546,14 @@ module Tina4
339
546
  { db: Tina4::ORM.db }.merge(@options[:handler_options] || {})
340
547
  )
341
548
  else
342
- Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
549
+ # Unreachable for a user's typo - validate_backend! already rejected it.
550
+ # Only a name that IS in VALID_BACKENDS but has no branch above can land
551
+ # here, which is a bug in this method rather than a configuration error,
552
+ # so it must not be swallowed into a file handler either.
553
+ raise ArgumentError,
554
+ "Session backend #{name.inspect} is listed in VALID_BACKENDS but " \
555
+ "has no handler branch. This is a framework bug, not a " \
556
+ "configuration error."
343
557
  end
344
558
  end
345
559
  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