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
@@ -46,12 +46,21 @@
46
46
  require "json"
47
47
  require "time"
48
48
  require "securerandom"
49
+ require "delegate"
49
50
 
50
51
  module Tina4
51
52
  module DocStore
52
53
  # Raised when a value cannot be parsed as an ObjectId.
53
54
  class InvalidId < ArgumentError; end
54
55
 
56
+ # A Mongo URI is configured but the MongoDB driver is not installed.
57
+ #
58
+ # ADR-0024 rule 3, settled for DocStore by ADR-0033: a provider that cannot
59
+ # honour an operation must RAISE, naming the provider and what is missing.
60
+ # Falling back to the local SQLite store here would send production writes
61
+ # to a container-local file nobody reads.
62
+ class DocStoreDriverMissing < StandardError; end
63
+
55
64
  OID_RE = /\A[0-9a-fA-F]{24}\z/.freeze
56
65
  ISO_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/.freeze
57
66
 
@@ -193,6 +202,45 @@ module Tina4
193
202
 
194
203
  def json_type(field) = "json_type(doc, '#{json_path(field)}')"
195
204
 
205
+ # A rowset over the field: one row per element of an array, one for a scalar.
206
+ def json_each(field) = "json_each(doc, '#{json_path(field)}')"
207
+
208
+ # True when the field is an ARRAY and any element satisfies +condition+.
209
+ #
210
+ # MongoDB's rule for an array-valued field is that a condition matches when
211
+ # ANY ELEMENT matches it. json_each yields one row per element, so EXISTS is
212
+ # the direct translation.
213
+ #
214
+ # The `= 'array'` guard is load-bearing: json_each over an OBJECT iterates
215
+ # its VALUES, and Mongo never matches an object field against one of its
216
+ # values - {"obj" => "x"} must NOT match {"obj" => {"city" => "x"}}.
217
+ def any_element(field, condition)
218
+ "(#{json_type(field)} = 'array' AND EXISTS (SELECT 1 FROM #{json_each(field)} WHERE #{condition}))"
219
+ end
220
+
221
+ # Compile `field == operand` under Mongo's array rule -> [sql, params].
222
+ def equality(field, operand)
223
+ ex = extract(field)
224
+ return ["#{ex} IS NULL", []] if operand.nil?
225
+
226
+ # An Array or Hash operand compares against the WHOLE value, never
227
+ # element-wise: {"tags" => ["x","y"]} is exact-array equality.
228
+ return ["#{ex} = ?", [bind(operand)]] if operand.is_a?(Array) || operand.is_a?(Hash)
229
+
230
+ ["(#{ex} = ? OR #{any_element(field, "value = ?")})", [bind(operand), bind(operand)]]
231
+ end
232
+
233
+ # Compile `field OP operand` under Mongo's array rule -> [sql, params].
234
+ #
235
+ # The `<> 'array'` guard on the scalar branch removes a measured FALSE
236
+ # POSITIVE: json_extract of an array returns its JSON TEXT, and SQLite sorts
237
+ # any text above any number, so {"nums" => {"$gt" => 9}} matched [1,2,3].
238
+ def compare(field, sql_op, operand)
239
+ ex = extract(field)
240
+ ["((#{json_type(field)} <> 'array' AND #{ex} #{sql_op} ?) OR #{any_element(field, "value #{sql_op} ?")})",
241
+ [bind(operand), bind(operand)]]
242
+ end
243
+
196
244
  # Compile a Mongo-style filter Hash into [sql_fragment, params].
197
245
  # Returns ["1=1", []] for an empty filter. Supports implicit AND across keys,
198
246
  # $or / $and, and the per-field operator set.
@@ -221,11 +269,12 @@ module Tina4
221
269
  clauses << frag
222
270
  params.concat(p)
223
271
  end
224
- elsif value.nil?
225
- clauses << "#{extract(key)} IS NULL"
226
272
  else
227
- clauses << "#{extract(key)} = ?"
228
- params << bind(value)
273
+ # equality - the same helper $eq uses, so the array rule applies
274
+ # whether the filter reads {"tags" => "x"} or {"tags" => {"$eq" => "x"}}
275
+ frag, p = equality(key, value)
276
+ clauses << frag
277
+ params.concat(p)
229
278
  end
230
279
  end
231
280
 
@@ -234,37 +283,40 @@ module Tina4
234
283
 
235
284
  def compile_op(field, op, operand)
236
285
  ex = extract(field)
237
- if COMPARATORS.key?(op)
238
- return ["#{ex} #{COMPARATORS[op]} ?", [bind(operand)]]
239
- end
286
+ return compare(field, COMPARATORS[op], operand) if COMPARATORS.key?(op)
240
287
 
241
288
  case op
242
289
  when "$eq"
243
- return ["#{ex} IS NULL", []] if operand.nil?
244
-
245
- ["#{ex} = ?", [bind(operand)]]
290
+ equality(field, operand)
246
291
  when "$ne"
247
292
  return ["#{ex} IS NOT NULL", []] if operand.nil?
248
293
 
249
- ["(#{ex} <> ? OR #{ex} IS NULL)", [bind(operand)]]
294
+ sql, p = equality(field, operand)
295
+ # A MISSING field satisfies $ne in Mongo, and SQL's NOT(NULL) is NULL
296
+ # rather than true - so the IS NULL arm is required, not decoration.
297
+ ["(NOT (#{sql}) OR #{ex} IS NULL)", p]
250
298
  when "$in"
251
299
  items = Array(operand)
252
300
  return ["0", []] if items.empty?
253
301
 
254
302
  placeholders = (["?"] * items.length).join(",")
255
- ["#{ex} IN (#{placeholders})", items.map { |v| bind(v) }]
303
+ bound = items.map { |v| bind(v) }
304
+ ["(#{ex} IN (#{placeholders}) OR #{any_element(field, "value IN (#{placeholders})")})", bound + bound]
256
305
  when "$nin"
257
306
  items = Array(operand)
258
307
  return ["1", []] if items.empty?
259
308
 
260
309
  placeholders = (["?"] * items.length).join(",")
261
- ["(#{ex} NOT IN (#{placeholders}) OR #{ex} IS NULL)", items.map { |v| bind(v) }]
310
+ bound = items.map { |v| bind(v) }
311
+ ["(NOT (#{ex} IN (#{placeholders}) OR #{any_element(field, "value IN (#{placeholders})")}) OR #{ex} IS NULL)",
312
+ bound + bound]
262
313
  when "$exists"
263
314
  # json_type is NULL when the path is absent; present-but-null still has a type.
264
315
  [operand ? "#{json_type(field)} IS NOT NULL" : "#{json_type(field)} IS NULL", []]
265
316
  when "$regex"
266
317
  pattern = operand.is_a?(Hash) ? operand["$regex"].to_s : operand.to_s
267
- ["#{ex} REGEXP ?", [pattern]]
318
+ ["((#{json_type(field)} <> 'array' AND #{ex} REGEXP ?) OR #{any_element(field, "value REGEXP ?")})",
319
+ [pattern, pattern]]
268
320
  else
269
321
  raise ArgumentError, "DocStore: unsupported query operator #{op.inspect}"
270
322
  end
@@ -292,8 +344,37 @@ module Tina4
292
344
  end
293
345
  end
294
346
 
347
+ # Normalise the driver's three sort spellings to an array of [key, direction].
348
+ #
349
+ # ADR-0036. Mongo::Collection::View#sort takes ONE spec document; the
350
+ # framework documents sort(key, direction); and a list of pairs is the third
351
+ # form pymongo and the Node driver both take. Measured 2026-08-04 against a
352
+ # real MongoDB, ALL THREE had to work on both providers before one spelling
353
+ # could be called portable: the two-argument form raised ArgumentError on
354
+ # the Ruby driver, and the pairs form reached the server as an ARRAY and
355
+ # came back "[14:TypeMismatch]: Expected field sort to be of type object".
356
+ def sort_spec(key_or_list, direction = 1)
357
+ case key_or_list
358
+ when String, Symbol then [[key_or_list.to_s, direction]]
359
+ else key_or_list.map { |key, sort_direction| [key.to_s, sort_direction] }
360
+ end
361
+ end
362
+
295
363
  # -- Projection / update helpers --------------------------------------------
296
364
 
365
+ # Decode a stored JSON document, rehydrating ObjectId and Time values.
366
+ #
367
+ # Module-level because it is a PURE function of its inputs. It was a public
368
+ # collection method only so the Cursor could reach it, which ADR-0025
369
+ # corollary 1 forbids.
370
+ def load_doc(doc_text, projection = nil)
371
+ doc = decode_value(JSON.parse(doc_text))
372
+ projection ? project(doc, projection) : doc
373
+ end
374
+
375
+ # Encode a document for storage. Pure, for the same reason as load_doc.
376
+ def dump_doc(document) = JSON.generate(encode_value(document))
377
+
297
378
  def project(doc, projection)
298
379
  return doc if projection.nil? || projection.empty?
299
380
 
@@ -392,8 +473,15 @@ module Tina4
392
473
  class Cursor
393
474
  include Enumerable
394
475
 
395
- def initialize(collection, where, params, projection = nil)
396
- @collection = collection
476
+ # The cursor receives WHAT IT NEEDS, not the collection it came from.
477
+ #
478
+ # It used to hold the collection and reach back into it for quoted_name,
479
+ # connection and load_doc - which was the ONLY reason those three were
480
+ # public. ADR-0025 corollary 1: anything the fallback needs internally is
481
+ # private, and a real Mongo::Collection::View exposes none of them.
482
+ def initialize(conn, quoted_name, where, params, projection = nil)
483
+ @conn = conn
484
+ @quoted_name = quoted_name
397
485
  @where = where
398
486
  @params = params
399
487
  @projection = projection
@@ -403,11 +491,7 @@ module Tina4
403
491
  end
404
492
 
405
493
  def sort(key_or_list, direction = 1)
406
- if key_or_list.is_a?(String) || key_or_list.is_a?(Symbol)
407
- @sort << [key_or_list.to_s, direction]
408
- else
409
- key_or_list.each { |k, d| @sort << [k.to_s, d] }
410
- end
494
+ @sort.concat(DocStore.sort_spec(key_or_list, direction))
411
495
  self
412
496
  end
413
497
 
@@ -422,7 +506,7 @@ module Tina4
422
506
  end
423
507
 
424
508
  def build_sql
425
- sql = "SELECT doc FROM #{@collection.quoted_name} WHERE #{@where}"
509
+ sql = "SELECT doc FROM #{@quoted_name} WHERE #{@where}"
426
510
  unless @sort.empty?
427
511
  order = @sort.map { |k, d| "#{DocStore.extract(k)} #{d.to_i.negative? ? 'DESC' : 'ASC'}" }.join(", ")
428
512
  sql += " ORDER BY #{order}"
@@ -439,9 +523,9 @@ module Tina4
439
523
  def each
440
524
  return enum_for(:each) unless block_given?
441
525
 
442
- @collection.connection.execute(build_sql, @params).each do |row|
526
+ @conn.execute(build_sql, @params).each do |row|
443
527
  doc_text = row.is_a?(Hash) ? (row["doc"] || row[:doc] || row.values.first) : row.first
444
- yield @collection.load_doc(doc_text, @projection)
528
+ yield DocStore.load_doc(doc_text, @projection)
445
529
  end
446
530
  end
447
531
 
@@ -450,15 +534,26 @@ module Tina4
450
534
  each { |doc| out << doc }
451
535
  out
452
536
  end
453
- alias to_list to_a
537
+
538
+ # The uniform Tina4 spelling, ADDITIVE to to_a (ADR-0035).
539
+ #
540
+ # ADR-0025 corollary 1 removed this because Mongo::Collection::View has no
541
+ # to_list. ADR-0035 amends that corollary: a method may exist here when it
542
+ # also exists on WHAT get_collection RETURNS for the real provider, and
543
+ # MongoView supplies it there. to_a stays - it is the driver's spelling
544
+ # and the Ruby idiom - so both work on both providers.
545
+ def to_list = to_a
546
+
547
+ # The generated SQL is an implementation detail, not part of the cursor
548
+ # API. Declared here rather than with a `private` section because build_sql
549
+ # is defined above and a trailing `private` would not reach it.
550
+ private :build_sql
454
551
  end
455
552
 
456
553
  # -- Collection -------------------------------------------------------------
457
554
 
458
555
  # A SQLite-backed collection exposing the everyday Mongo API.
459
556
  class SqliteCollection
460
- attr_reader :connection
461
-
462
557
  def initialize(conn, name)
463
558
  @connection = conn
464
559
  @name = name.to_s
@@ -470,23 +565,13 @@ module Tina4
470
565
  )
471
566
  end
472
567
 
473
- def quoted_name = @quoted_name
474
-
475
- # -- helpers --
476
- def dump(document) = JSON.generate(DocStore.encode_value(document))
477
-
478
- def load_doc(doc_text, projection = nil)
479
- doc = DocStore.decode_value(JSON.parse(doc_text))
480
- projection ? DocStore.project(doc, projection) : doc
481
- end
482
-
483
568
  # -- writes --
484
569
  def insert_one(document)
485
570
  doc = stringify(document)
486
571
  doc["_id"] = ObjectId.new unless doc.key?("_id")
487
572
  @connection.execute(
488
573
  "INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
489
- [DocStore.id_key(doc["_id"]), dump(doc)]
574
+ [DocStore.id_key(doc["_id"]), DocStore.dump_doc(doc)]
490
575
  )
491
576
  InsertOneResult.new(doc["_id"])
492
577
  end
@@ -499,7 +584,7 @@ module Tina4
499
584
  ids << doc["_id"]
500
585
  @connection.execute(
501
586
  "INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
502
- [DocStore.id_key(doc["_id"]), dump(doc)]
587
+ [DocStore.id_key(doc["_id"]), DocStore.dump_doc(doc)]
503
588
  )
504
589
  end
505
590
  InsertManyResult.new(ids)
@@ -508,11 +593,18 @@ module Tina4
508
593
  # -- reads --
509
594
  def find(filter = nil, projection = nil)
510
595
  where, params = DocStore.compile_filter(filter || {})
511
- Cursor.new(self, where, params, projection)
596
+ Cursor.new(@connection, @quoted_name, where, params, projection)
512
597
  end
513
598
 
599
+ # The uniform Tina4 spelling, ADDITIVE to find(filter).first (ADR-0035).
600
+ #
601
+ # Mongo::Collection has no find_one, which is why ADR-0025 corollary 1
602
+ # deleted this. ADR-0035 amends the means, not the goal: MongoCollection
603
+ # supplies find_one on the driver side, so the method now exists with the
604
+ # same meaning on BOTH halves of the swap. The signature is pymongo's,
605
+ # which is what the Python master already ships.
514
606
  def find_one(filter = nil, projection = nil)
515
- find(filter, projection).limit(1).to_a.first
607
+ find(filter, projection).first
516
608
  end
517
609
 
518
610
  def count_documents(filter = nil)
@@ -626,7 +718,7 @@ module Tina4
626
718
  new_key = DocStore.id_key(new_doc["_id"])
627
719
  @connection.execute(
628
720
  "UPDATE #{@quoted_name} SET _id = ?, doc = ? WHERE _id = ?",
629
- [new_key, dump(new_doc), old_id]
721
+ [new_key, DocStore.dump_doc(new_doc), old_id]
630
722
  )
631
723
  true
632
724
  end
@@ -652,6 +744,67 @@ module Tina4
652
744
  end
653
745
  end
654
746
 
747
+ # -- The driver delegators (ADR-0035) -----------------------------------------
748
+ #
749
+ # ADR-0025 costed "wrap the driver" as a hand-written FACADE over 12-14
750
+ # methods, and rejected it because that makes aggregate, bulk_write,
751
+ # indexes, watch, sessions and transactions unreachable. That objection is
752
+ # fatal to a facade and irrelevant to a DELEGATOR: SimpleDelegator forwards
753
+ # EVERYTHING untouched, so the entire driver surface stays reachable and we
754
+ # add exactly one method per class.
755
+ #
756
+ # These exist so the uniform Tina4 spelling works on BOTH providers rather
757
+ # than on the fallback alone - which is the thing ADR-0025 corollary 1 was
758
+ # right to forbid, and ADR-0035 supplies instead of deleting.
759
+
760
+ # A Mongo::Collection::View that also answers to_list, and accepts every
761
+ # sort spelling the fallback Cursor accepts.
762
+ class MongoView < SimpleDelegator
763
+ # to_a is the driver's spelling and stays authoritative; to_list is the
764
+ # uniform Tina4 spelling the SQLite Cursor also answers.
765
+ def to_list = __getobj__.to_a
766
+
767
+ # ADR-0036: ONE sort contract on both providers.
768
+ #
769
+ # Mongo::Collection::View#sort takes a single spec document, so
770
+ # sort("total", -1) raised ArgumentError and sort([["total", -1]]) reached
771
+ # the server as an array and came back TypeMismatch. Normalising here means
772
+ # all three spellings work on the driver exactly as they do on the
773
+ # fallback - which is the point of the swap.
774
+ def sort(key_or_list, direction = 1)
775
+ MongoView.new(__getobj__.sort(DocStore.sort_spec(key_or_list, direction).to_h))
776
+ end
777
+
778
+ # A View is IMMUTABLE - sort/limit/skip/projection each return a NEW View.
779
+ # Without re-wrapping, the first chained call would hand back a bare View
780
+ # and to_list would vanish mid-chain: exactly the "works until you sort
781
+ # it" surprise this ADR exists to stop.
782
+ def method_missing(name, *args, &block)
783
+ result = super
784
+ result.is_a?(::Mongo::Collection::View) ? MongoView.new(result) : result
785
+ end
786
+ ruby2_keywords :method_missing
787
+ end
788
+
789
+ # A Mongo::Collection that also answers find_one, and whose find returns a
790
+ # cursor answering to_list.
791
+ class MongoCollection < SimpleDelegator
792
+ # Pure pass-through plus wrapping. The argument list is NOT re-declared,
793
+ # so the driver keeps deciding what find accepts.
794
+ def find(*args, &block)
795
+ MongoView.new(__getobj__.find(*args, &block))
796
+ end
797
+ ruby2_keywords :find
798
+
799
+ # pymongo's signature, which the Python master already ships. On the
800
+ # driver a projection is an OPTION rather than a positional, so it is
801
+ # spelled the driver's way here; the meaning is identical.
802
+ def find_one(filter = nil, projection = nil)
803
+ view = projection ? __getobj__.find(filter || {}, projection: projection) : __getobj__.find(filter || {})
804
+ view.first
805
+ end
806
+ end
807
+
655
808
  # -- Database + selection -----------------------------------------------------
656
809
 
657
810
  # A SQLite-backed document database (a file of collection tables).
@@ -694,27 +847,35 @@ module Tina4
694
847
 
695
848
  module_function
696
849
 
850
+ # The env var that supplied the URI, or nil when none did.
851
+ #
852
+ # Named separately so an error can tell the operator WHICH variable to
853
+ # unset without ever printing its value - a Mongo URI routinely carries
854
+ # `user:password@`.
855
+ #
856
+ # Canonical TINA4_MONGO_URI, then the session-layer TINA4_SESSION_MONGO_URI;
857
+ # TINA4_SESSION_MONGO_URL is a legacy alias.
858
+ def mongo_uri_source
859
+ %w[TINA4_MONGO_URI TINA4_SESSION_MONGO_URI TINA4_SESSION_MONGO_URL]
860
+ .find { |name| !(ENV[name] || "").strip.empty? }
861
+ end
862
+
697
863
  # The configured Mongo URI, reusing the app-wide queue/session env vars.
698
- # Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
699
864
  def mongo_uri
700
- (ENV["TINA4_MONGO_URI"] ||
701
- ENV["TINA4_SESSION_MONGO_URI"] ||
702
- ENV["TINA4_SESSION_MONGO_URL"] ||
703
- "").strip
865
+ source = mongo_uri_source
866
+ source ? ENV[source].strip : ""
704
867
  end
705
868
 
706
869
  # True when no Mongo is configured, so the SQLite fallback is in effect.
870
+ #
871
+ # CONFIGURATION ONLY. Before 3.13.95 this also returned true when a URI was
872
+ # set but the mongo gem was absent, and that is precisely what made
873
+ # get_collection hand back the local SQLite store while the operator
874
+ # believed they were on Mongo. A missing driver is now an error (ADR-0033),
875
+ # not a second way to be serverless - otherwise an app branching on this
876
+ # would take the local path and never reach the raise.
707
877
  def serverless?
708
- return true if mongo_uri.empty?
709
-
710
- begin
711
- require "mongo"
712
- false
713
- rescue LoadError
714
- # A URI is set but the driver is absent: degrade to the local store
715
- # rather than crash.
716
- true
717
- end
878
+ mongo_uri.empty?
718
879
  end
719
880
 
720
881
  @default_db = nil
@@ -733,13 +894,66 @@ module Tina4
733
894
  # A real Mongo driver Collection when a Mongo URI is configured (and the
734
895
  # mongo gem is installed); otherwise a SqliteCollection backed by the local
735
896
  # SQLite file. Same call sites either way - only the backend differs.
897
+ #
898
+ # The Mongo collection is handed back through MongoCollection, a
899
+ # SimpleDelegator that adds the uniform Tina4 spellings and forwards
900
+ # everything else untouched (ADR-0035). What this method RETURNS is the
901
+ # surface a call site sees, so it is the surface the contract compares.
736
902
  def get_collection(name)
737
903
  return default_db.get_collection(name) if serverless?
738
904
 
739
- require "mongo"
740
905
  db_name = ENV["TINA4_MONGO_DB"] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
741
- client = Mongo::Client.new(mongo_uri, database: db_name)
742
- client[name]
906
+ MongoCollection.new(mongo_client(mongo_uri, db_name)[name])
907
+ end
908
+
909
+ @mongo_clients = {}
910
+
911
+ # Return the shared Mongo client for this (uri, database), connecting once.
912
+ #
913
+ # MEASURED 2026-08-03 against a real MongoDB: get_collection used to build a
914
+ # new Mongo::Client on EVERY call and never close it, so 20 calls left 60
915
+ # server connections open and the count grew without bound. It was invisible
916
+ # in development because the SQLite fallback has no connections at all - a
917
+ # resource leak that only exists AFTER the swap to the real provider.
918
+ #
919
+ # A Mongo::Client is thread-safe and pools internally, so one per
920
+ # (uri, database) is the shape the driver itself expects. The double-checked
921
+ # lock matches default_db above: without it two threads racing the first call
922
+ # both build a client and one is orphaned - the same leak, just rarer.
923
+ #
924
+ # A missing gem raises here, at provider RESOLUTION, before any socket is
925
+ # opened: it is a static fact and needs no network to establish.
926
+ def mongo_client(uri, db_name)
927
+ begin
928
+ require "mongo"
929
+ rescue LoadError
930
+ source = mongo_uri_source || "TINA4_MONGO_URI"
931
+ raise DocStoreDriverMissing,
932
+ "Tina4 DocStore: #{source} is set, so the MongoDB provider is selected, but " \
933
+ "its driver is not installed (gem 'mongo'). Install it with `gem install mongo`, " \
934
+ "or unset #{source} to use the local SQLite store."
935
+ end
936
+ key = [uri, db_name]
937
+ client = @mongo_clients[key]
938
+ return client if client
939
+
940
+ @default_lock.synchronize do
941
+ @mongo_clients[key] ||= Mongo::Client.new(uri, database: db_name)
942
+ end
943
+ end
944
+
945
+ # Close every DocStore connection: the SQLite store and all Mongo clients.
946
+ def close_doc_store
947
+ @default_lock.synchronize do
948
+ @mongo_clients.each_value do |client|
949
+ client.close
950
+ rescue StandardError
951
+ nil # a close failure must not mask the caller's work
952
+ end
953
+ @mongo_clients.clear
954
+ @default_db&.close
955
+ @default_db = nil
956
+ end
743
957
  end
744
958
 
745
959
  # Drop the cached default SQLite store (test helper).
@@ -1,8 +1,10 @@
1
+ require "set"
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module Tina4
4
5
  module Drivers
5
6
  class FirebirdDriver
7
+ include Tina4::DatabaseAdapter
6
8
  attr_reader :connection
7
9
 
8
10
  # Substring markers (lowercased) that identify a dead-socket Firebird
@@ -111,6 +113,55 @@ module Tina4
111
113
  url_charset || kwarg || env || "UTF8"
112
114
  end
113
115
 
116
+ # Bound the REACH to a Firebird server before libfbclient is handed the
117
+ # attach. Raises the shared connect-timeout error when the host does not
118
+ # answer within TINA4_DATABASE_CONNECT_TIMEOUT.
119
+ #
120
+ # Why a socket probe and not a timeout around the attach - MEASURED on
121
+ # Ruby 3.2.3 / Ubuntu 24.04.4 / fb 0.10.0 against a REAL TCPServer that
122
+ # accepts the connection and then never replies:
123
+ #
124
+ # fb attach, no bound WEDGED past 20s, SIGKILL needed
125
+ # fb attach, Timeout.timeout(3) WEDGED past 20s - the timeout NEVER fired
126
+ # fb attach, Thread#join(3) WEDGED past 20s - join never returned
127
+ #
128
+ # The gem calls isc_attach_database (fb.c:3002) WITHOUT releasing the GVL
129
+ # and without an unblocking function, so no Ruby thread runs to deliver
130
+ # the interrupt - `timeout`'s SIGTERM could not even be delivered. It also
131
+ # builds its DPB from a fixed four-item set (user, password, lc_ctype,
132
+ # role) with no connect-timeout item. There is therefore NO in-process way
133
+ # to bound the attach itself, and a Timeout.timeout here would be WORSE
134
+ # than nothing because it would look like protection and silently not fire.
135
+ #
136
+ # What IS boundable is reaching the host at all, which is the hang
137
+ # operators actually hit: a dead or firewalled server swallows the SYN and
138
+ # the process sits there. A plain stdlib socket bounds that exactly.
139
+ #
140
+ # RESIDUAL GAP, stated plainly so this is not mistaken for full cover: if
141
+ # the TCP handshake SUCCEEDS and the server then never speaks the Firebird
142
+ # protocol, the attach is still UNBOUNDED. Ruby cannot fix that from
143
+ # inside this process - it needs a connect timeout in the fb gem itself.
144
+ #
145
+ # ONLY a timeout is converted. A refused connection, an unknown host or
146
+ # any other socket error is swallowed so libfbclient still produces its
147
+ # own (better) diagnosis: this probe adds a bound, it does not take over
148
+ # error reporting.
149
+ def self.bound_reachability!(host, port)
150
+ seconds = Tina4::DatabaseAdapter.connect_timeout_seconds
151
+ return if seconds.nil? || host.to_s.empty?
152
+
153
+ require "socket"
154
+ begin
155
+ Tina4::DatabaseAdapter.bounding_connect(host, port) do
156
+ Socket.tcp(host, port, connect_timeout: seconds, &:close)
157
+ end
158
+ rescue Tina4::DatabaseConnectionError
159
+ raise
160
+ rescue StandardError
161
+ nil
162
+ end
163
+ end
164
+
114
165
  def connect(connection_string, username: nil, password: nil, charset: nil)
115
166
  require "fb"
116
167
  require "uri"
@@ -155,6 +206,7 @@ module Tina4
155
206
  # with the gem's default charset (double-encoding). Defaults to UTF8.
156
207
  @connect_opts[:charset] = self.class.resolve_charset(connection_string, charset)
157
208
 
209
+ self.class.bound_reachability!(host, port)
158
210
  open_connection
159
211
  rescue LoadError
160
212
  raise LoadError,
@@ -209,8 +261,12 @@ module Tina4
209
261
  (["?"] * count).join(", ")
210
262
  end
211
263
 
264
+ # The closing paren goes on a NEW LINE. Inline, a trailing `-- comment` in
265
+ # the caller's SQL comments the paren out and the whole wrapped statement
266
+ # is a syntax error (the same class of bug as the LIMIT append site — see
267
+ # the note on Drivers::SqliteDriver#apply_limit).
212
268
  def apply_limit(sql, limit, offset = 0)
213
- "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
269
+ "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql}\n)"
214
270
  end
215
271
 
216
272
  # Transaction handling — mirrors the Python master's connection-level
@@ -237,6 +293,20 @@ module Tina4
237
293
  @in_transaction = true
238
294
  end
239
295
 
296
+ # Guarded on the CONNECTION only, deliberately - no active-transaction check
297
+ # is needed here.
298
+ #
299
+ # Python's adapter has the same shape and it IS a bug there: firebird-driver
300
+ # delegates Connection#commit to main_transaction, whose handle is nil until
301
+ # a statement opens one, so committing with nothing open raises
302
+ # "AttributeError: 'NoneType' object has no attribute 'commit'". Measured
303
+ # 2026-08-04 against the lab's real Firebird 5.0.4, the `fb` gem does NOT
304
+ # behave that way: both #commit and #rollback on a fresh connection with no
305
+ # open transaction return cleanly, because the driver opens one implicitly.
306
+ #
307
+ # So do not "port" Python's is_active? guard here on the strength of the
308
+ # shape matching - it would be dead code guarding a condition this driver
309
+ # cannot reach.
240
310
  def commit
241
311
  @connection&.commit
242
312
  @in_transaction = false
@@ -259,13 +329,33 @@ module Tina4
259
329
  "JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
260
330
  "WHERE RF.RDB\$RELATION_NAME = ?"
261
331
  rows = execute_query(sql, [table_name.upcase])
332
+
333
+ # The primary key comes from the constraint catalogue. This used to be
334
+ # hardcoded `false` for every column, so primary_key(table) always
335
+ # answered [] on Firebird -- which silently breaks anything that
336
+ # introspects the key, including the filterless-write guard that lifts
337
+ # the PK out of `data`. Same bug the Python master carried.
338
+ pk_sql = "SELECT SG.RDB\$FIELD_NAME FROM RDB\$INDEX_SEGMENTS SG " \
339
+ "JOIN RDB\$RELATION_CONSTRAINTS RC ON SG.RDB\$INDEX_NAME = RC.RDB\$INDEX_NAME " \
340
+ "WHERE RC.RDB\$CONSTRAINT_TYPE = 'PRIMARY KEY' AND RC.RDB\$RELATION_NAME = ? " \
341
+ "ORDER BY SG.RDB\$FIELD_POSITION"
342
+ pk_names = begin
343
+ execute_query(pk_sql, [table_name.upcase]).map do |r|
344
+ (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip.upcase
345
+ end.reject(&:empty?).to_set
346
+ rescue StandardError
347
+ # A table with no primary key is not an error.
348
+ Set.new
349
+ end
350
+
262
351
  rows.map do |r|
352
+ field_name = (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip
263
353
  {
264
- name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
354
+ name: field_name,
265
355
  type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
266
356
  nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
267
357
  default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
268
- primary_key: false
358
+ primary_key: pk_names.include?(field_name.upcase)
269
359
  }
270
360
  end
271
361
  end
@@ -301,7 +391,31 @@ module Tina4
301
391
  end
302
392
 
303
393
  def stringify_keys(hash)
304
- hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
394
+ hash.each_with_object({}) { |(k, v), h| h[column_name(k.to_s)] = v }
395
+ end
396
+
397
+ # Firebird's stored column name, folded back only when it was folded.
398
+ #
399
+ # Firebird's identifier folding is ASYMMETRIC. An unquoted `AS x` is
400
+ # stored UPPERCASE, so the driver hands back "X" where every other engine
401
+ # Tina4 supports gives "x" -- PostgreSQL folds to lower, and MySQL, SQLite
402
+ # and MSSQL preserve what you wrote. Portable code reading row["x"] broke
403
+ # on Firebird alone; the live URL examples in this repo asserted row["x"]
404
+ # and could never have passed once they actually reached a server.
405
+ #
406
+ # A QUOTED `AS "MyCol"` is stored exactly as written, and that case is
407
+ # deliberate -- the caller asked for it -- so it is left alone. Folding
408
+ # unconditionally makes a mixed-case key unreachable, the same asymmetric
409
+ # trap that made table_exists? miss quoted tables.
410
+ #
411
+ # So: fold back only a name carrying no lowercase letter, the only thing
412
+ # unquoted folding can produce. A quoted ALL-CAPS name is genuinely
413
+ # indistinguishable from a folded one and is lowercased too; that
414
+ # ambiguity is Firebird's, and it is the one spelling this cannot
415
+ # round-trip.
416
+ def column_name(raw)
417
+ name = raw.strip
418
+ name == name.upcase ? name.downcase : name
305
419
  end
306
420
 
307
421
  # Ensure Firebird BLOB columns are proper byte strings.