tina4ruby 3.13.93 → 3.13.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -153,6 +153,116 @@ module Tina4
|
|
|
153
153
|
raw = params ? "#{sql}|#{params.inspect}" : sql
|
|
154
154
|
"query:#{Digest::SHA256.hexdigest(raw)}"
|
|
155
155
|
end
|
|
156
|
+
|
|
157
|
+
# Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
|
|
158
|
+
#
|
|
159
|
+
# A batch that loops one INSERT per row pays a full network round-trip per
|
|
160
|
+
# row, and the round-trip - not SQL building - is the entire cost of a
|
|
161
|
+
# batch write. Measured over 500 rows: PostgreSQL 9848ms row-at-a-time
|
|
162
|
+
# against 15.8ms as a single multi-row statement (625x), MySQL 216x,
|
|
163
|
+
# MSSQL 121x.
|
|
164
|
+
#
|
|
165
|
+
# PURE: no I/O and no engine contact, so the chunking rules are checkable
|
|
166
|
+
# without a database. The live-engine runners prove the rows land.
|
|
167
|
+
#
|
|
168
|
+
# @param sql [String] the single-row INSERT the batch would loop
|
|
169
|
+
# @param params_list [Array<Array>] one entry per row
|
|
170
|
+
# @param engine [String] engine name as the driver reports it (aliases ok)
|
|
171
|
+
# @return [Array<Array(String, Array)>] statements to run INSTEAD of the
|
|
172
|
+
# loop, or an EMPTY array meaning "not collapsible - keep looping",
|
|
173
|
+
# which is always correct.
|
|
174
|
+
# Normalise a collapsed batch's last id to the LAST row's id.
|
|
175
|
+
#
|
|
176
|
+
# A row-at-a-time batch reports the last row's id simply because the last
|
|
177
|
+
# statement inserted the last row. Collapsing rows into one statement
|
|
178
|
+
# changes that on any engine that reports the FIRST generated id, so this
|
|
179
|
+
# restores the contract instead of quietly redefining it.
|
|
180
|
+
#
|
|
181
|
+
# Verified live, not assumed: a 3-row insert into a fresh MySQL table
|
|
182
|
+
# reports 1 while MAX(id) is 3. SQLite, PostgreSQL and MSSQL already
|
|
183
|
+
# report the last and are left alone. The ids in one statement are
|
|
184
|
+
# consecutive, so the last is +first + rows - 1+.
|
|
185
|
+
def batch_last_id(reported_id, rows_in_chunk, engine)
|
|
186
|
+
name = engine.to_s.downcase
|
|
187
|
+
name = ENGINE_ALIASES.fetch(name, name)
|
|
188
|
+
return reported_id unless FIRST_ID_ENGINES.include?(name)
|
|
189
|
+
return reported_id unless reported_id.is_a?(Integer) || reported_id.to_s.match?(/\A-?\d+\z/)
|
|
190
|
+
|
|
191
|
+
reported_id.to_i + [rows_in_chunk.to_i, 1].max - 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_batch_inserts(sql, params_list, engine)
|
|
195
|
+
rows = params_list || []
|
|
196
|
+
return [] if rows.length < 2
|
|
197
|
+
|
|
198
|
+
name = engine.to_s.downcase
|
|
199
|
+
name = ENGINE_ALIASES.fetch(name, name)
|
|
200
|
+
cap = MAX_BIND_PARAMS.fetch(name, 0)
|
|
201
|
+
# Firebird has no multi-row VALUES syntax; ODBC's real ceiling depends on
|
|
202
|
+
# the driver behind it. Emitting SQL the engine cannot parse to save a
|
|
203
|
+
# round-trip is not a trade worth making.
|
|
204
|
+
return [] if cap <= 0
|
|
205
|
+
|
|
206
|
+
upper = sql.upcase
|
|
207
|
+
# A collapsed statement returns N rows where the caller expects one, and
|
|
208
|
+
# conflict arbitration changes once rows share a statement.
|
|
209
|
+
return [] if upper.include?("RETURNING") ||
|
|
210
|
+
upper.include?("ON CONFLICT") ||
|
|
211
|
+
upper.include?("ON DUPLICATE KEY")
|
|
212
|
+
|
|
213
|
+
match = INSERT_VALUES.match(sql)
|
|
214
|
+
return [] if match.nil?
|
|
215
|
+
|
|
216
|
+
# Every slot must be a bare placeholder. `now()` repeated per row inside
|
|
217
|
+
# one statement is not the same write as `now()` evaluated per statement.
|
|
218
|
+
slots = match[1].split(",").map(&:strip)
|
|
219
|
+
return [] if slots.empty? || slots.any? { |slot| slot != "?" }
|
|
220
|
+
|
|
221
|
+
columns = slots.length
|
|
222
|
+
return [] if rows.any? { |params| params.length != columns }
|
|
223
|
+
|
|
224
|
+
chunk_rows = [1, cap / columns].max
|
|
225
|
+
return [] if chunk_rows < 2
|
|
226
|
+
|
|
227
|
+
head = sql[0...(match.begin(1) - 1)].rstrip
|
|
228
|
+
one_row = "(#{Array.new(columns, '?').join(', ')})"
|
|
229
|
+
|
|
230
|
+
rows.each_slice(chunk_rows).map do |chunk|
|
|
231
|
+
["#{head} #{Array.new(chunk.length, one_row).join(', ')}", chunk.flatten(1)]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
156
234
|
end
|
|
235
|
+
|
|
236
|
+
# Hard per-statement bind-parameter ceiling per engine. 0 = never collapse.
|
|
237
|
+
# Sourced from spec/fixtures/batch_write_contract.json, byte-identical in
|
|
238
|
+
# all four frameworks.
|
|
239
|
+
MAX_BIND_PARAMS = {
|
|
240
|
+
"sqlite" => 999,
|
|
241
|
+
"postgres" => 65_535,
|
|
242
|
+
"mysql" => 65_535,
|
|
243
|
+
"mssql" => 2_100,
|
|
244
|
+
"firebird" => 0,
|
|
245
|
+
"odbc" => 0,
|
|
246
|
+
"mongodb" => 0
|
|
247
|
+
}.freeze
|
|
248
|
+
|
|
249
|
+
# The four frameworks do not agree on what an engine calls itself - Python
|
|
250
|
+
# and PHP report "postgresql", Ruby and Node report "postgres". Without
|
|
251
|
+
# normalising, the cap lookup misses and the collapse silently does nothing
|
|
252
|
+
# on the engine with the largest win.
|
|
253
|
+
ENGINE_ALIASES = {
|
|
254
|
+
"postgresql" => "postgres",
|
|
255
|
+
"pgsql" => "postgres",
|
|
256
|
+
"sqlite3" => "sqlite",
|
|
257
|
+
"sqlserver" => "mssql",
|
|
258
|
+
"sqlsrv" => "mssql",
|
|
259
|
+
"mariadb" => "mysql"
|
|
260
|
+
}.freeze
|
|
261
|
+
|
|
262
|
+
INSERT_VALUES = /\A\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*\z/im
|
|
263
|
+
|
|
264
|
+
# Engines whose last_insert_id reports the FIRST generated id of a multi-row
|
|
265
|
+
# INSERT rather than the last. Verified live against MySQL.
|
|
266
|
+
FIRST_ID_ENGINES = %w[mysql].freeze
|
|
157
267
|
end
|
|
158
268
|
end
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -87,8 +87,14 @@ module Tina4
|
|
|
87
87
|
def base_spec
|
|
88
88
|
info = {
|
|
89
89
|
"title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
# info.version is the APPLICATION's API version, not the framework's.
|
|
91
|
+
# Defaulting to Tina4::VERSION made an undocumented app claim API
|
|
92
|
+
# v3.13.x; "1.0.0" is the settled cross-framework default (Python/PHP).
|
|
93
|
+
# TINA4_SWAGGER_VERSION still overrides.
|
|
94
|
+
"version" => ENV["TINA4_SWAGGER_VERSION"] || "1.0.0",
|
|
95
|
+
# Empty by default (parity) — a canned "Auto-generated..." blurb is
|
|
96
|
+
# noise in a real API's docs. Set TINA4_SWAGGER_DESCRIPTION to fill it.
|
|
97
|
+
"description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || ""
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
# Optional contact block — only emitted when at least one field is set.
|
|
@@ -228,6 +234,8 @@ module Tina4
|
|
|
228
234
|
tags = meta[:tags] || [extract_tag(route.path)]
|
|
229
235
|
tags.each { |t| ctx[:used_tags] << t unless ctx[:used_tags].include?(t) }
|
|
230
236
|
|
|
237
|
+
security = resolve_security(meta, route, ctx[:schemes])
|
|
238
|
+
|
|
231
239
|
spec["paths"][path] ||= {}
|
|
232
240
|
operation = {
|
|
233
241
|
"operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
|
|
@@ -239,10 +247,18 @@ module Tina4
|
|
|
239
247
|
}
|
|
240
248
|
|
|
241
249
|
operation["deprecated"] = true if meta[:deprecated]
|
|
242
|
-
|
|
243
|
-
security = resolve_security(meta, route, ctx[:schemes])
|
|
244
250
|
operation["security"] = security unless security.nil?
|
|
245
251
|
|
|
252
|
+
# A route emits 401 WHEN AND ONLY WHEN it is documented as secured (a
|
|
253
|
+
# non-empty security requirement). An undecorated route carries only
|
|
254
|
+
# 200; nothing invents 400/404/500, and an explicitly public route
|
|
255
|
+
# (security == []) gets no 401. Mirrors PHP; drops the old
|
|
256
|
+
# default_responses that stamped 200/400/401/404/500 on every operation
|
|
257
|
+
# including a public GET.
|
|
258
|
+
if security && !security.empty? && !operation["responses"].key?("401")
|
|
259
|
+
operation["responses"]["401"] = { "description" => "Unauthorized" }
|
|
260
|
+
end
|
|
261
|
+
|
|
246
262
|
if %w[post put patch].include?(method)
|
|
247
263
|
operation["requestBody"] = build_request_body(method, meta, ref, ctx)
|
|
248
264
|
end
|
|
@@ -259,7 +275,18 @@ module Tina4
|
|
|
259
275
|
return reqs.empty? ? [] : sanitize_security(reqs, schemes)
|
|
260
276
|
end
|
|
261
277
|
|
|
262
|
-
|
|
278
|
+
# auth_required is what DISPATCH enforces: true by default on
|
|
279
|
+
# POST/PUT/PATCH/DELETE, flipped by `.secure` / `.no_auth`. This branch
|
|
280
|
+
# read `auth_handler` instead, which defaults to nil and is set only by
|
|
281
|
+
# the `auth:` keyword or the secure_* helpers - so every write route
|
|
282
|
+
# registered the ordinary way was enforced-secured and documented
|
|
283
|
+
# PUBLIC, and `.secure` on a GET never reached the document at all.
|
|
284
|
+
# MEASURED: POST /api/items answered 401 while its operation carried no
|
|
285
|
+
# security key, in the framework's own AutoCrud output too.
|
|
286
|
+
#
|
|
287
|
+
# Both flags are honoured. A custom auth_handler protects a route even
|
|
288
|
+
# when auth_required is false for its method, so it is still documented.
|
|
289
|
+
return sanitize_security([{ default_scheme => [] }], schemes) if route.auth_required || route.auth_handler
|
|
263
290
|
|
|
264
291
|
nil
|
|
265
292
|
end
|
|
@@ -364,8 +391,13 @@ module Tina4
|
|
|
364
391
|
end
|
|
365
392
|
end
|
|
366
393
|
|
|
394
|
+
# A route's success response. An undecorated route emits ONLY 200
|
|
395
|
+
# (description-only); with a model ref the 200 carries the schema. The
|
|
396
|
+
# 401-on-secured addition happens in add_route_to_spec, not here.
|
|
367
397
|
def model_or_default_responses(ref, model_list)
|
|
368
|
-
|
|
398
|
+
unless ref
|
|
399
|
+
return { "200" => { "description" => "Successful response" } }
|
|
400
|
+
end
|
|
369
401
|
|
|
370
402
|
schema = model_list ? { "type" => "array", "items" => { "$ref" => ref } } : { "$ref" => ref }
|
|
371
403
|
{
|
|
@@ -393,9 +425,19 @@ module Tina4
|
|
|
393
425
|
first
|
|
394
426
|
end
|
|
395
427
|
|
|
428
|
+
# operationId is a generated client's METHOD NAME, so two distinct paths
|
|
429
|
+
# must produce two distinct ids. Preserve the path's own underscores —
|
|
430
|
+
# /__health -> get___health, /health -> get_health — instead of collapsing
|
|
431
|
+
# both to get_health and suffixing _2 onto whichever registered second
|
|
432
|
+
# (order-dependent). Mirrors the Python master: strip the outer slashes,
|
|
433
|
+
# turn internal "/" into "_", drop the {} around params, map a splat to
|
|
434
|
+
# "wildcard", and DO NOT collapse repeated underscores.
|
|
396
435
|
def unique_operation_id(method, path, seen)
|
|
397
|
-
|
|
398
|
-
|
|
436
|
+
clean = path.gsub(%r{\A/+|/+\z}, "")
|
|
437
|
+
.gsub("/", "_")
|
|
438
|
+
.delete("{}")
|
|
439
|
+
.gsub("*", "wildcard")
|
|
440
|
+
base = clean.empty? ? method : "#{method}_#{clean}"
|
|
399
441
|
oid = base
|
|
400
442
|
n = 2
|
|
401
443
|
while seen.include?(oid)
|
|
@@ -486,16 +528,6 @@ module Tina4
|
|
|
486
528
|
nil
|
|
487
529
|
end
|
|
488
530
|
end
|
|
489
|
-
|
|
490
|
-
def default_responses
|
|
491
|
-
{
|
|
492
|
-
"200" => { "description" => "Successful response" },
|
|
493
|
-
"400" => { "description" => "Bad request" },
|
|
494
|
-
"401" => { "description" => "Unauthorized" },
|
|
495
|
-
"404" => { "description" => "Not found" },
|
|
496
|
-
"500" => { "description" => "Internal server error" }
|
|
497
|
-
}
|
|
498
|
-
end
|
|
499
531
|
end
|
|
500
532
|
end
|
|
501
533
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -5,13 +5,16 @@ module Tina4
|
|
|
5
5
|
DEFAULT_HOST = "0.0.0.0"
|
|
6
6
|
DEFAULT_PORT = 7147
|
|
7
7
|
|
|
8
|
-
#
|
|
8
|
+
# Bind address and port, when the caller does not pass host:/port:.
|
|
9
|
+
#
|
|
10
|
+
# Both go through Tina4.resolve_bind_* so this and Tina4.start! cannot
|
|
11
|
+
# drift apart - they already had: this file read TINA4_PORT first while
|
|
12
|
+
# tina4.rb read bare PORT first, so the same variable meant different
|
|
13
|
+
# things depending which entry point you came through.
|
|
9
14
|
def initialize(app, host: nil, port: nil)
|
|
10
15
|
@app = app
|
|
11
|
-
|
|
12
|
-
@
|
|
13
|
-
env_port = ENV["TINA4_PORT"] || ENV["PORT"]
|
|
14
|
-
@port = port || (env_port && !env_port.empty? ? env_port.to_i : DEFAULT_PORT)
|
|
16
|
+
@host = host || Tina4.resolve_bind_host(DEFAULT_HOST)
|
|
17
|
+
@port = port || Tina4.resolve_bind_port(DEFAULT_PORT)
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
# Kill whatever process is listening on *port*.
|
|
@@ -130,7 +133,18 @@ module Tina4
|
|
|
130
133
|
end
|
|
131
134
|
|
|
132
135
|
define_method(:handle_request) do |webrick_req, webrick_res|
|
|
133
|
-
#
|
|
136
|
+
# Belt-and-braces, NOT the primary mechanism. Shutdown closes the
|
|
137
|
+
# listening socket FIRST, so a connection arriving after the signal is
|
|
138
|
+
# refused by the kernel and never reaches here. What is left is a
|
|
139
|
+
# genuine race: WEBrick accepts a connection and parses its request in
|
|
140
|
+
# a worker thread, and the @shutting_down flag can flip in the gap
|
|
141
|
+
# before that worker reaches this line.
|
|
142
|
+
#
|
|
143
|
+
# Measured (macOS 26.5.2, Ruby 4.0.2, webrick 1.9.2, 48 threads
|
|
144
|
+
# hammering across a real SIGTERM): the window is the ~10-90ms between
|
|
145
|
+
# the flag flip and the listener actually closing, and 0-2 requests per
|
|
146
|
+
# run land in it out of ~2000. Every request after that is
|
|
147
|
+
# ECONNREFUSED. Rare, but reachable - so the guard stays.
|
|
134
148
|
if Tina4::Shutdown.shutting_down?
|
|
135
149
|
webrick_res.status = 503
|
|
136
150
|
webrick_res.body = '{"error":"Service shutting down"}'
|
|
@@ -232,6 +246,8 @@ module Tina4
|
|
|
232
246
|
end
|
|
233
247
|
|
|
234
248
|
define_method(:handle_request) do |webrick_req, webrick_res|
|
|
249
|
+
# Same accepted-a-moment-before-the-listener-closed race as the
|
|
250
|
+
# main servlet above - see the comment there.
|
|
235
251
|
if Tina4::Shutdown.shutting_down?
|
|
236
252
|
webrick_res.status = 503
|
|
237
253
|
webrick_res.body = '{"error":"Service shutting down"}'
|
|
@@ -299,6 +315,12 @@ module Tina4
|
|
|
299
315
|
end
|
|
300
316
|
|
|
301
317
|
@server.start
|
|
318
|
+
|
|
319
|
+
# Shutdown closes the listener FIRST, so #start returns as soon as the
|
|
320
|
+
# in-flight workers are joined - potentially while the signal handler's
|
|
321
|
+
# thread is still stopping background tasks and closing the database.
|
|
322
|
+
# Wait for that teardown instead of exiting out from under it.
|
|
323
|
+
Tina4::Shutdown.wait_for_completion
|
|
302
324
|
end
|
|
303
325
|
|
|
304
326
|
def stop
|