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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -140
data/lib/tina4/response.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 "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
|
-
|
|
114
|
+
detected_content_type(JSON_CONTENT_TYPE)
|
|
89
115
|
@body = JSON.generate(data)
|
|
90
116
|
else
|
|
91
|
-
|
|
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"] =
|
|
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"] =
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/tina4/response_cache.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
|
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
|
-
|
|
860
|
-
rows = db.fetch("SELECT #{
|
|
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[
|
|
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
|
|
5
|
-
# License:
|
|
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
|
data/lib/tina4/service_runner.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
|
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
|
-
#
|
|
62
|
+
# ONE TRANSPORT PER PROCESS (issue #136, parity with tina4-python #136).
|
|
61
63
|
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
@collection = @client[@collection_name]
|
|
197
|
-
ensure_ttl_index
|
|
267
|
+
[:gem, @uri, @database]
|
|
198
268
|
else
|
|
199
|
-
@
|
|
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
|
|
262
|
-
#
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|