tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require "uri"
4
10
 
@@ -59,11 +65,30 @@ module Tina4
59
65
 
60
66
  attr_accessor :status_code, :headers, :body, :cookies
61
67
 
68
+ # ADR-0068: a header name must be an RFC 9110 token, and a header value,
69
+ # redirect location or cookie attribute may never carry CR, LF or NUL (a
70
+ # cookie attribute not ';' either). They are REFUSED where the developer
71
+ # sets them - never stripped - so the stack trace points at the line that
72
+ # built the value. The messages are identical in all four frameworks.
73
+ HEADER_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
74
+ UNSAFE_HEADER_VALUE = /[\r\n\0]/
75
+ UNSAFE_COOKIE_CONTENT = /[\r\n\0;]/
76
+
77
+ def self.check_header!(name, value)
78
+ unless name.to_s.match?(HEADER_TOKEN)
79
+ raise ArgumentError, "Header name must be a valid HTTP token [#{JSON.generate(name.to_s)}]"
80
+ end
81
+ return value unless value.to_s.match?(UNSAFE_HEADER_VALUE)
82
+
83
+ raise ArgumentError, "Invalid character in header content [#{JSON.generate(name.to_s)}]"
84
+ end
85
+
62
86
  def initialize
63
87
  @status_code = 200
64
88
  @headers = { "content-type" => HTML_CONTENT_TYPE }
65
89
  @body = ""
66
90
  @cookies = nil # Lazy -- most responses have no cookies
91
+ @content_type_from_header = false
67
92
  end
68
93
 
69
94
  # Chainable status setter
@@ -79,16 +104,17 @@ module Tina4
79
104
  # Callable response — auto-detects content type from data.
80
105
  # Matches Python __call__ / PHP __invoke / Node response() pattern.
81
106
  def call(data = nil, status_code = 200, content_type = nil)
107
+ Response.check_header!("Content-Type", content_type) if content_type
82
108
  @status_code = status_code
83
109
  data = jsonable(data)
84
110
  if content_type
85
111
  @headers["content-type"] = content_type
86
- @body = data.to_s
112
+ @body = data.is_a?(Hash) || data.is_a?(Array) ? JSON.generate(data) : data.to_s
87
113
  elsif data.is_a?(Hash) || data.is_a?(Array)
88
- @headers["content-type"] = JSON_CONTENT_TYPE
114
+ detected_content_type(JSON_CONTENT_TYPE)
89
115
  @body = JSON.generate(data)
90
116
  else
91
- @headers["content-type"] = HTML_CONTENT_TYPE
117
+ detected_content_type(HTML_CONTENT_TYPE)
92
118
  @body = data.to_s
93
119
  end
94
120
  self
@@ -142,14 +168,16 @@ module Tina4
142
168
  end
143
169
 
144
170
  def csv(content, filename: "export.csv", status: 200)
171
+ disposition = Response.check_header!("Content-Disposition", "attachment; filename=\"#{filename}\"")
145
172
  @status_code = status
146
173
  @headers["content-type"] = "text/csv"
147
- @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
174
+ @headers["content-disposition"] = disposition
148
175
  @body = content.to_s
149
176
  self
150
177
  end
151
178
 
152
179
  def redirect(url, status_or_opts = nil, status: nil)
180
+ Response.check_header!("Location", url)
153
181
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
154
182
  @headers["location"] = url
155
183
  @body = ""
@@ -157,6 +185,7 @@ module Tina4
157
185
  end
158
186
 
159
187
  def file(path, content_type: nil, download: false, root: nil)
188
+ Response.check_header!("Content-Type", content_type) if content_type
160
189
  # SECURITY: confine the read. The natural spelling of a download route,
161
190
  #
162
191
  # response.file("downloads/" + name) # name = "../secret.env"
@@ -206,7 +235,8 @@ module Tina4
206
235
  ext = ::File.extname(path).downcase
207
236
  @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
208
237
  if download
209
- @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
238
+ @headers["content-disposition"] =
239
+ Response.check_header!("Content-Disposition", "attachment; filename=\"#{::File.basename(path)}\"")
210
240
  end
211
241
  @body = ::File.binread(path)
212
242
  self
@@ -279,12 +309,14 @@ module Tina4
279
309
  { error: true, code: code, message: message, status: status }
280
310
  end
281
311
 
282
- # Chainable header setter
312
+ # Chainable header setter. Content-Type (any case) is not a second header:
313
+ # it replaces the response's one content type, and call(data) keeps it
314
+ # instead of detecting one (ADR-0072, tina4-python#144).
283
315
  def header(name, value = nil)
284
316
  if value.nil?
285
- @headers[name]
317
+ @headers[content_type_key?(name) ? "content-type" : name]
286
318
  else
287
- @headers[name] = value
319
+ set_header(name, Response.check_header!(name, value))
288
320
  self
289
321
  end
290
322
  end
@@ -294,7 +326,16 @@ module Tina4
294
326
  set_cookie(name, value, opts)
295
327
  end
296
328
 
329
+ # The value is percent-encoded on the way out, so it can never carry CR,
330
+ # LF, NUL or ';' to the wire and is not refused (ADR-0068 keeps encoding
331
+ # where a framework already did). The name and every attribute are refused.
297
332
  def set_cookie(name, value, opts = {})
333
+ label = JSON.generate(name.to_s)
334
+ raise ArgumentError, "Cookie name must be a valid HTTP token [#{label}]" unless name.to_s.match?(HEADER_TOKEN)
335
+ if [opts[:path], opts[:same_site], opts[:max_age]].any? { |attribute| attribute.to_s.match?(UNSAFE_COOKIE_CONTENT) }
336
+ raise ArgumentError, "Invalid character in cookie content [#{label}]"
337
+ end
338
+
298
339
  cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
299
340
  cookie_str += "; Path=#{opts[:path] || '/'}"
300
341
  cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
@@ -312,7 +353,7 @@ module Tina4
312
353
  end
313
354
 
314
355
  def add_header(key, value)
315
- @headers[key] = value
356
+ set_header(key, Response.check_header!(key, value))
316
357
  self
317
358
  end
318
359
 
@@ -353,6 +394,7 @@ module Tina4
353
394
  # @yield [Enumerator::Yielder] Block receives a yielder to push chunks
354
395
  # @return [self]
355
396
  def stream(generator = nil, content_type: "text/event-stream", &block)
397
+ Response.check_header!("Content-Type", content_type)
356
398
  @status_code = @status_code || 200
357
399
  @headers["content-type"] = content_type
358
400
  @headers["cache-control"] = "no-cache"
@@ -366,6 +408,7 @@ module Tina4
366
408
 
367
409
  # Finalize and return the response — matches Python/Node API.
368
410
  def send(data = nil, status_code: nil, content_type: nil)
411
+ Response.check_header!("Content-Type", content_type) if content_type
369
412
  if data
370
413
  if data.is_a?(Hash) || data.is_a?(Array)
371
414
  return json(data, status_code || 200)
@@ -378,6 +421,25 @@ module Tina4
378
421
  to_rack
379
422
  end
380
423
 
424
+ def content_type_key?(name)
425
+ name.to_s.casecmp?("content-type")
426
+ end
427
+
428
+ def set_header(name, value)
429
+ if content_type_key?(name)
430
+ @headers["content-type"] = value
431
+ @content_type_from_header = true
432
+ else
433
+ @headers[name] = value
434
+ end
435
+ end
436
+
437
+ # A detected content type never replaces one the route set with header().
438
+ def detected_content_type(content_type)
439
+ @headers["content-type"] = content_type unless @content_type_from_header
440
+ end
441
+ private :content_type_key?, :set_header, :detected_content_type
442
+
381
443
  def to_rack
382
444
  final_headers = @headers.dup
383
445
  final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Multi-backend response cache for GET requests.
data/lib/tina4/router.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "fileutils"
4
10
  require "json"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ module Tina4
9
+ # Internal credential persistence: validate and read/write the same descriptor.
10
+ module SecretFile
11
+ def self.update(path)
12
+ begin
13
+ before = File.lstat(path)
14
+ raise IOError, "credential target must be an unlinked regular file" unless before.file? && before.nlink == 1
15
+ rescue Errno::ENOENT
16
+ # The O_CREAT open below creates the file with owner-only permissions.
17
+ end
18
+ flags = File::RDWR | File::CREAT
19
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
20
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
21
+ File.open(path, flags, 0o600) do |file|
22
+ file.flock(File::LOCK_EX)
23
+ stat = file.stat
24
+ raise IOError, "credential target must be an unlinked regular file" unless stat.file? && stat.nlink == 1
25
+ file.chmod(0o600)
26
+ content = yield file.read
27
+ file.rewind
28
+ file.truncate(0)
29
+ file.write(content)
30
+ file.flush
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/tina4/seeder.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "securerandom"
4
10
  require "json"
@@ -856,10 +862,10 @@ module Tina4
856
862
  db = ref_class.get_db
857
863
  next unless db
858
864
 
859
- pk = ref_class.primary_key_field || :id
860
- rows = db.fetch("SELECT #{pk} FROM #{ref_class.table_name}", [], limit: 100_000)
865
+ pk_column = ref_class.get_db_column(ref_class.primary_key_field || :id)
866
+ rows = db.fetch("SELECT #{pk_column} FROM #{ref_class.table_name}", [], limit: 100_000)
861
867
  list = rows.respond_to?(:to_a) ? rows.to_a : Array(rows)
862
- values = list.map { |r| r[pk] || r[pk.to_s] }.compact
868
+ values = list.map { |r| r[pk_column] || r[pk_column.to_s] }.compact
863
869
  pools[name] = values unless values.empty?
864
870
  rescue => e
865
871
  Tina4::Log.warning("Seeder: could not resolve FK pool for #{name}: #{e.message}")
data/lib/tina4/service.rb CHANGED
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 — The Intelligent Native Application 4ramework
4
- # Copyright 2007 - current Tina4
5
- # License: MIT https://opensource.org/licenses/MIT
10
+ # Copyright (c) 2026 Code Infinity
11
+ # License: MPL-2.0 https://mozilla.org/MPL/2.0/
6
12
 
7
13
  module Tina4
8
14
  # Base class for class-based background services managed by
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Context object passed to each service handler, giving it control
data/lib/tina4/session.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "securerandom"
3
9
  require "json"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require "fileutils"
4
10
  require "digest"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "digest"
4
10
  require "json"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "mongo_wire_client"
4
10
 
@@ -51,36 +57,90 @@ module Tina4
51
57
  @database = options[:database] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
52
58
  @collection_name = options[:collection] || ENV["TINA4_SESSION_MONGO_COLLECTION"] || "sessions"
53
59
  @gem_available = gem_available?
54
- @client = nil
55
- @wire_client = nil
56
- @collection = nil
57
- @index_ready = false
58
60
  end
59
61
 
60
- # Release whichever transport was opened.
62
+ # ONE TRANSPORT PER PROCESS (issue #136, parity with tina4-python #136).
61
63
  #
62
- # Mongo::Client owns a pool of REAL sockets. Before the client was lazy it
63
- # was a local variable in #initialize, reachable only through the
64
- # collection, so every construction leaked a pool and nothing could give it
65
- # back. Now the handler holds the client, so the handler can close it -
66
- # parity with the Python master's MongoDBSessionHandler.close() and with
67
- # Tina4::DocStore.close_doc_store, which already closes its Mongo clients
68
- # the same way. The zero-dependency transport owns ONE raw socket and is
69
- # closed the same way, so neither path leaks. Safe to call on a handler
70
- # that never connected.
71
- def close
72
- # Each close is guarded on its own: a failure on one transport must not
73
- # skip the other, and neither may mask the caller's work.
74
- [@client, @wire_client].each do |transport|
64
+ # Every request builds a Session and every Session builds a NEW handler,
65
+ # so a transport owned by the handler is a transport per REQUEST. The
66
+ # handler used to open its own Mongo::Client on first use - each with its
67
+ # own SDAM monitor threads and connection pool - and nothing ever closed
68
+ # it. MEASURED on the lab: 21 requests left 87 extra threads behind; on the
69
+ # zero-dependency path 40 sessions left 40 sockets open to MongoDB.
70
+ #
71
+ # A Mongo::Client is thread-safe and pools internally, so the driver
72
+ # expects exactly one per (uri, database) - the same shape
73
+ # Tina4::DocStore.mongo_client already uses. The zero-dependency
74
+ # MongoWireClient is bound to one collection and serialises its commands
75
+ # on its single socket, so it is shared per (uri, database, collection).
76
+ # Handlers hold no transport of their own: they look it up here on every
77
+ # operation, so a released transport is simply rebuilt on next use.
78
+ @shared = {}
79
+ @index_ready = {}
80
+ @shared_lock = Mutex.new
81
+
82
+ class << self
83
+ # The shared transport for +key+, built by the block on first use. The
84
+ # double-checked lock stops two threads racing the first request from
85
+ # both building a client and orphaning one - the same leak, just rarer.
86
+ def shared_transport(key)
87
+ transport = @shared[key]
88
+ return transport if transport
89
+
90
+ @shared_lock.synchronize { @shared[key] ||= yield }
91
+ end
92
+
93
+ # True exactly once per process for +key+ - used so the TTL index round
94
+ # trip runs once per (store, ttl), not once per request.
95
+ def claim_index(key)
96
+ @shared_lock.synchronize do
97
+ next false if @index_ready[key]
98
+
99
+ @index_ready[key] = true
100
+ end
101
+ end
102
+
103
+ # Close and forget the shared transport for +key+ (MongoHandler#close).
104
+ def release_shared(key)
105
+ close_transport(@shared_lock.synchronize { @shared.delete(key) })
106
+ end
107
+
108
+ # Close every shared session transport - process shutdown, or a test
109
+ # that must leave no connection behind. Safe to call at any time: the
110
+ # next session operation simply reconnects.
111
+ def close_shared_clients
112
+ transports = @shared_lock.synchronize do
113
+ values = @shared.values
114
+ @shared.clear
115
+ @index_ready.clear
116
+ values
117
+ end
118
+ transports.each { |transport| close_transport(transport) }
119
+ true
120
+ end
121
+
122
+ private
123
+
124
+ # A close failure must never mask the caller's work.
125
+ def close_transport(transport)
75
126
  transport&.close
76
127
  rescue StandardError
77
128
  nil
78
129
  end
79
- ensure
80
- @client = nil
81
- @wire_client = nil
82
- @collection = nil
83
- @index_ready = false
130
+ end
131
+
132
+ # Release the transport this handler's configuration uses.
133
+ #
134
+ # The transport is SHARED per process (see .shared_transport), so this
135
+ # closes it for every handler pointed at the same store - exactly what an
136
+ # owner that is done with the store wants, and harmless to the rest: they
137
+ # look the transport up on every operation and reconnect on next use. The
138
+ # Mongo::Client (a pool of real sockets) and the zero-dependency socket
139
+ # are both closed this way, so neither path leaks. Safe to call on a
140
+ # handler that never connected. Parity with the Python master's
141
+ # MongoDBSessionHandler.close() and Tina4::DocStore.close_doc_store.
142
+ def close
143
+ self.class.release_shared(transport_key)
84
144
  end
85
145
 
86
146
  # Decide whether a stored document has expired, FROM THE DOCUMENT ALONE.
@@ -189,17 +249,25 @@ module Tina4
189
249
  # handler takes the other. A per-operation branch is a branch that can be
190
250
  # wrong on one path only - which is exactly how a fallback ships untested.
191
251
  def collection
192
- return @collection if @collection
252
+ if @gem_available
253
+ client = self.class.shared_transport(transport_key) { Mongo::Client.new(@uri, database: @database) }
254
+ collection = client[@collection_name]
255
+ ensure_ttl_index(collection)
256
+ collection
257
+ else
258
+ self.class.shared_transport(transport_key) { build_wire_client }
259
+ end
260
+ end
193
261
 
262
+ # The registry key for this handler's transport: one Mongo::Client per
263
+ # (uri, database); one wire client per (uri, database, collection), since
264
+ # a MongoWireClient is bound to a single collection.
265
+ def transport_key
194
266
  if @gem_available
195
- @client = Mongo::Client.new(@uri, database: @database)
196
- @collection = @client[@collection_name]
197
- ensure_ttl_index
267
+ [:gem, @uri, @database]
198
268
  else
199
- @wire_client = build_wire_client
200
- @collection = @wire_client
269
+ [:wire, @uri, @database, @collection_name]
201
270
  end
202
- @collection
203
271
  end
204
272
 
205
273
  # The zero-dependency transport, pointed at the host and port parsed out of
@@ -258,18 +326,19 @@ module Tina4
258
326
  # does not exist when the gem is absent, so evaluating them would raise
259
327
  # NameError rather than the LoadError anyone would expect.
260
328
  #
261
- # Runs ONCE per handler (@index_ready), on the first operation. The flag is
262
- # set BEFORE the round trip so an unreachable server is not re-probed on
263
- # every read - the same ordering the Python master uses for _table_ready.
264
- def ensure_ttl_index
265
- return if @index_ready
329
+ # Runs ONCE per process for each (store, collection, ttl), on the first
330
+ # operation - not once per handler, which is once per REQUEST (issue
331
+ # #136). The claim is taken BEFORE the round trip so an unreachable server
332
+ # is not re-probed on every read - the same ordering the Python master
333
+ # uses for _table_ready.
334
+ def ensure_ttl_index(collection)
335
+ return unless self.class.claim_index([@uri, @database, @collection_name, @ttl])
266
336
 
267
- @index_ready = true
268
- @collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
337
+ collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
269
338
  rescue Mongo::Error::OperationFailure => e
270
339
  raise unless e.code == 85 || e.message.include?("IndexOptionsConflict")
271
- @collection.indexes.drop_one("updated_at_1")
272
- @collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
340
+ collection.indexes.drop_one("updated_at_1")
341
+ collection.indexes.create_one({ updated_at: 1 }, expire_after_seconds: @ttl)
273
342
  rescue Mongo::Error => e
274
343
  # The index is the background REAPER, not the expiry authority - #read
275
344
  # checks expires_at on the document itself - so failing to create it must
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "socket"
4
10
 
@@ -71,6 +77,10 @@ module Tina4
71
77
  @timeout = timeout
72
78
  @socket = nil
73
79
  @request_id = 0
80
+ # One socket carries one request/reply at a time. The client is SHARED
81
+ # by every session in the process (MongoHandler.shared_transport, issue
82
+ # #136), so concurrent requests must not interleave their frames.
83
+ @lock = Mutex.new
74
84
  end
75
85
 
76
86
  # Documents matching +filter+, capped at one.
@@ -142,15 +152,19 @@ module Tina4
142
152
  # transport failure, so the socket is dropped and the next command
143
153
  # reconnects rather than inheriting the damage.
144
154
  def exchange(document)
145
- socket = connection
146
- @request_id += 1
147
- body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
148
- header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
149
- socket.write(header + body)
150
- read_reply(socket)
151
- rescue StandardError => e
152
- close
153
- raise MongoWireError, "MongoDB transport failed: #{e.message}"
155
+ @lock.synchronize do
156
+ socket = connection
157
+ @request_id += 1
158
+ body = "\x00\x00\x00\x00".b + "\x00".b + encode_document(document) # flagBits(4) + section kind 0
159
+ header = [16 + body.bytesize, @request_id, 0, OP_MSG].pack("V4")
160
+ socket.write(header + body)
161
+ read_reply(socket)
162
+ rescue StandardError => e
163
+ # Still under the lock: no other session can be mid-exchange on the
164
+ # socket being dropped. The next command reconnects.
165
+ close
166
+ raise MongoWireError, "MongoDB transport failed: #{e.message}"
167
+ end
154
168
  end
155
169
 
156
170
  def connection
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "resp_client"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "socket"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require_relative "resp_client"
4
10