tina4ruby 3.13.94 → 3.13.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +194 -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 +243 -29
  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
@@ -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
- "version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
91
- "description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
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
- return sanitize_security([{ default_scheme => [] }], schemes) if route.auth_handler
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
- return default_responses unless ref
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
- base = method + path.gsub(%r{[/{}*]}) { |c| c == "*" ? "wildcard" : "_" }
398
- base = base.gsub(/_+/, "_").chomp("_")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.94"
4
+ VERSION = "3.13.96"
5
5
  end
@@ -5,13 +5,16 @@ module Tina4
5
5
  DEFAULT_HOST = "0.0.0.0"
6
6
  DEFAULT_PORT = 7147
7
7
 
8
- # TINA4_HOST overrides the bind address when caller doesn't pass `host:`.
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
- env_host = ENV["TINA4_HOST"]
12
- @host = host || (env_host && !env_host.empty? ? env_host : DEFAULT_HOST)
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
- # Reject new requests during shutdown
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