tina4ruby 3.13.38 → 3.13.39
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +20 -4
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +25 -5
- data/lib/tina4/metrics.rb +115 -28
- data/lib/tina4/migration.rb +107 -20
- data/lib/tina4/orm.rb +182 -21
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +6 -1
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +80 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62dc42240a6abd35572f17207f42c631ebf2c36cc73b94adad4b163b3a2924a5
|
|
4
|
+
data.tar.gz: e9ea35e0aad4bee25bc42f7ee7a37973144265ed538b5e6127e5a546278a62cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58cf9b41857e5905b5eeda37a6881f812aad230a12533051d4510724222453f344ae1a0e05120e915a69450a05788dad7468eb1b99925e44a5bd8a625dca4c59
|
|
7
|
+
data.tar.gz: 8ff5447f091aafe2ff0a06c7e23a8f7838190545ec96f99bc2329753ee0a9ed0714cc1a7293d127805efe504552293fbb6f08e3fa7651e8423998cc8939869f5
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="https://tina4.com/logo.svg" alt="Tina4" width="200">
|
|
3
3
|
</p>
|
|
4
4
|
<h1 align="center">Tina4 Ruby</h1>
|
|
5
|
-
<h3 align="center">TINA4
|
|
5
|
+
<h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
|
|
6
6
|
<p align="center"><em>Simple. Fast. Human. | Built for AI. Built for you.</em></p>
|
|
7
7
|
<p align="center">55 built-in features. Zero runtime dependencies. One require, everything works.</p>
|
|
8
8
|
<p align="center">
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
# With the Tina4 CLI (recommended
|
|
21
|
+
# With the Tina4 CLI (recommended, enables SCSS + live reload)
|
|
22
22
|
cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
|
|
23
23
|
tina4 init ruby ./my-app
|
|
24
24
|
cd my-app && tina4 serve
|
|
@@ -56,12 +56,12 @@ db = Tina4::Database.new("sqlite://app.db")
|
|
|
56
56
|
| Category | Features |
|
|
57
57
|
|----------|----------|
|
|
58
58
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
59
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
59
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
60
60
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
61
61
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
62
62
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
63
63
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
64
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
64
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
65
65
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
66
66
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
67
67
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -84,14 +84,14 @@ tina4ruby generate model <name>
|
|
|
84
84
|
|
|
85
85
|
## Performance
|
|
86
86
|
|
|
87
|
-
Benchmarked with `wrk
|
|
87
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
88
88
|
|
|
89
89
|
| Framework | JSON req/s | Deps | Features |
|
|
90
90
|
|-----------|-----------|------|----------|
|
|
91
91
|
| **Tina4 Ruby** | **10,243** | 0 | 55 |
|
|
92
92
|
| Sinatra | 9,548 | 5+ | ~4 |
|
|
93
93
|
|
|
94
|
-
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4
|
|
94
|
+
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4**, with zero runtime dependencies.
|
|
95
95
|
|
|
96
96
|
**Across all 4 Tina4 implementations:**
|
|
97
97
|
|
|
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4** — with z
|
|
|
105
105
|
|
|
106
106
|
## Cross-Framework Parity
|
|
107
107
|
|
|
108
|
-
Tina4 ships identical features across four languages
|
|
108
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
109
109
|
|
|
110
110
|
| | Python | PHP | Ruby | Node.js |
|
|
111
111
|
|---|--------|-----|------|---------|
|
data/lib/tina4/api.rb
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "net/http"
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "uri"
|
|
4
5
|
require "json"
|
|
5
6
|
require "base64"
|
|
6
7
|
|
|
7
8
|
module Tina4
|
|
9
|
+
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
10
|
+
# (429) plus the transient server-side 5xx family. 4xx client errors (401,
|
|
11
|
+
# 404, …) are NOT retried — a repeat won't succeed. Parity with the Python
|
|
12
|
+
# master's _RETRY_STATUSES.
|
|
13
|
+
API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
8
15
|
class API
|
|
9
16
|
attr_reader :base_url, :headers
|
|
10
17
|
|
|
@@ -18,9 +25,16 @@ module Tina4
|
|
|
18
25
|
# api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
|
|
19
26
|
#
|
|
20
27
|
# Bearer wins over basic-auth when both are passed.
|
|
28
|
+
#
|
|
29
|
+
# 3.13.39: +max_retries / +retry_backoff enable opt-in automatic retry with
|
|
30
|
+
# exponential backoff (default max_retries: 0 = off, non-breaking) on a
|
|
31
|
+
# transport error (APIResponse#status == 0) or a retryable status
|
|
32
|
+
# (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
|
|
33
|
+
# re-sent — retries are opt-in for exactly that reason. Parity with the
|
|
34
|
+
# Python master.
|
|
21
35
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
22
36
|
bearer_token: nil, username: nil, password: nil,
|
|
23
|
-
verify_ssl: nil)
|
|
37
|
+
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5)
|
|
24
38
|
@base_url = base_url.chomp("/")
|
|
25
39
|
@headers = {
|
|
26
40
|
"Content-Type" => "application/json",
|
|
@@ -28,6 +42,8 @@ module Tina4
|
|
|
28
42
|
}.merge(headers)
|
|
29
43
|
@timeout = timeout
|
|
30
44
|
@verify_ssl = verify_ssl
|
|
45
|
+
@max_retries = [0, max_retries.to_i].max
|
|
46
|
+
@retry_backoff = retry_backoff.to_f
|
|
31
47
|
|
|
32
48
|
# Bearer wins over basic-auth when both passed
|
|
33
49
|
if bearer_token
|
|
@@ -142,9 +158,35 @@ module Tina4
|
|
|
142
158
|
end
|
|
143
159
|
end
|
|
144
160
|
|
|
161
|
+
# Execute the request with opt-in retry/backoff. Returns an APIResponse.
|
|
162
|
+
#
|
|
163
|
+
# With @max_retries > 0, a transport error (APIResponse#status == 0, the
|
|
164
|
+
# existing error sentinel) or a retryable status (429/5xx) is retried up to
|
|
165
|
+
# @max_retries times with exponential backoff (@retry_backoff seconds base,
|
|
166
|
+
# doubling each attempt); any other outcome (2xx, 3xx, other 4xx) returns at
|
|
167
|
+
# once. Parity with the Python master's _request.
|
|
145
168
|
def execute(uri, request)
|
|
169
|
+
attempts = @max_retries + 1
|
|
170
|
+
response = nil
|
|
171
|
+
(0...attempts).each do |attempt|
|
|
172
|
+
response = attempt_request(uri, request)
|
|
173
|
+
code = response.status
|
|
174
|
+
retryable = code.zero? || API_RETRY_STATUSES.include?(code)
|
|
175
|
+
return response if !retryable || attempt == attempts - 1
|
|
176
|
+
|
|
177
|
+
sleep(@retry_backoff * (2**attempt))
|
|
178
|
+
end
|
|
179
|
+
response
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A single HTTP attempt. Returns the standardized APIResponse.
|
|
183
|
+
def attempt_request(uri, request)
|
|
146
184
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
185
|
http.use_ssl = uri.scheme == "https"
|
|
186
|
+
# 3.13.39: honour verify_ssl: false (the dead-since-3.13.1 kwarg). Only
|
|
187
|
+
# disable verification when EXPLICITLY false — nil/true keep the secure
|
|
188
|
+
# default (OpenSSL::SSL::VERIFY_PEER).
|
|
189
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
148
190
|
http.open_timeout = @timeout
|
|
149
191
|
http.read_timeout = @timeout
|
|
150
192
|
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -287,6 +287,10 @@ module Tina4
|
|
|
287
287
|
status_icon = r[:status] == "success" ? "OK" : "FAIL"
|
|
288
288
|
puts " [#{status_icon}] #{r[:name]}"
|
|
289
289
|
end
|
|
290
|
+
# FAIL-FAST: a failed migration must give CI a non-zero exit (parity
|
|
291
|
+
# with the Python master). Only the startup auto-migration hook
|
|
292
|
+
# swallows failures; the explicit CLI does not.
|
|
293
|
+
exit 1 if results.any? { |r| r[:status] == "failed" }
|
|
290
294
|
end
|
|
291
295
|
end
|
|
292
296
|
end
|
data/lib/tina4/database.rb
CHANGED
|
@@ -255,6 +255,17 @@ module Tina4
|
|
|
255
255
|
end
|
|
256
256
|
end
|
|
257
257
|
|
|
258
|
+
# Autocommit is ON by default — parity with Python/PHP/Node. A standalone
|
|
259
|
+
# write (execute/insert/update/delete made OUTSIDE an explicit
|
|
260
|
+
# start_transaction()/commit() block) commits on its own connection before
|
|
261
|
+
# returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
|
|
262
|
+
# treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
|
|
263
|
+
# write needs an explicit commit). Inside an explicit transaction the
|
|
264
|
+
# framework-issued commit is suppressed (gated on the thread tx-pin), so
|
|
265
|
+
# explicit transactions stay atomic. Mirrors Python's
|
|
266
|
+
# DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
|
|
267
|
+
@autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))
|
|
268
|
+
|
|
258
269
|
# Register this connection so Tina4::Database.reset_request_caches can
|
|
259
270
|
# clear its request-scoped entries at the start of every HTTP request.
|
|
260
271
|
Tina4::Database.register_instance(self)
|
|
@@ -282,10 +293,11 @@ module Tina4
|
|
|
282
293
|
@driver.connect(@connection_string, username: @username, password: @password)
|
|
283
294
|
@connected = true
|
|
284
295
|
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
296
|
+
# Push the resolved autocommit setting down to the driver when it exposes a
|
|
297
|
+
# native toggle (default ON — see @autocommit in #initialize). The
|
|
298
|
+
# framework-level commit in #autocommit_standalone_write covers drivers
|
|
299
|
+
# that have no native setter.
|
|
300
|
+
@driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
|
|
289
301
|
|
|
290
302
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
291
303
|
rescue => e
|
|
@@ -488,7 +500,9 @@ module Tina4
|
|
|
488
500
|
placeholders = drv.placeholders(columns.length)
|
|
489
501
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
490
502
|
drv.execute(sql, data.values)
|
|
491
|
-
|
|
503
|
+
last_id = drv.last_insert_id
|
|
504
|
+
autocommit_standalone_write(drv)
|
|
505
|
+
{ success: true, last_id: last_id }
|
|
492
506
|
end
|
|
493
507
|
|
|
494
508
|
def update(table, data, filter = {}, params = nil)
|
|
@@ -501,6 +515,7 @@ module Tina4
|
|
|
501
515
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
502
516
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
503
517
|
drv.execute(sql, data.values + Array(params))
|
|
518
|
+
autocommit_standalone_write(drv)
|
|
504
519
|
return { success: true }
|
|
505
520
|
end
|
|
506
521
|
|
|
@@ -510,6 +525,7 @@ module Tina4
|
|
|
510
525
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
511
526
|
values = data.values + filter.values
|
|
512
527
|
drv.execute(sql, values)
|
|
528
|
+
autocommit_standalone_write(drv)
|
|
513
529
|
{ success: true }
|
|
514
530
|
end
|
|
515
531
|
|
|
@@ -528,6 +544,7 @@ module Tina4
|
|
|
528
544
|
sql = "DELETE FROM #{table}"
|
|
529
545
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
530
546
|
drv.execute(sql, Array(params))
|
|
547
|
+
autocommit_standalone_write(drv)
|
|
531
548
|
return { success: true }
|
|
532
549
|
end
|
|
533
550
|
|
|
@@ -536,6 +553,7 @@ module Tina4
|
|
|
536
553
|
sql = "DELETE FROM #{table}"
|
|
537
554
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
538
555
|
drv.execute(sql, filter.values)
|
|
556
|
+
autocommit_standalone_write(drv)
|
|
539
557
|
{ success: true }
|
|
540
558
|
end
|
|
541
559
|
|
|
@@ -582,8 +600,10 @@ module Tina4
|
|
|
582
600
|
# or a clean { error: } payload respectively.
|
|
583
601
|
def execute(sql, params = [])
|
|
584
602
|
cache_invalidate if @cache_enabled
|
|
585
|
-
|
|
603
|
+
drv = current_driver
|
|
604
|
+
result = drv.execute(sql, params)
|
|
586
605
|
@last_error = nil
|
|
606
|
+
autocommit_standalone_write(drv)
|
|
587
607
|
sql_upper = sql.strip.upcase
|
|
588
608
|
if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
|
|
589
609
|
sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
|
|
@@ -1104,6 +1124,31 @@ module Tina4
|
|
|
1104
1124
|
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
1105
1125
|
end
|
|
1106
1126
|
|
|
1127
|
+
# Durability: commit a standalone write so it actually persists.
|
|
1128
|
+
#
|
|
1129
|
+
# Called after a write (execute/insert/update/delete) issued OUTSIDE an
|
|
1130
|
+
# explicit transaction. The commit is suppressed when autocommit is off
|
|
1131
|
+
# (TINA4_AUTOCOMMIT=false, strict manual mode) OR when a transaction is open
|
|
1132
|
+
# on this thread (the thread tx-pin is set) — so an explicit
|
|
1133
|
+
# start_transaction()/commit() block stays atomic and is never broken up by
|
|
1134
|
+
# a per-statement commit. A commit with no transaction in progress is a
|
|
1135
|
+
# harmless no-op on every engine (SQLite swallows the specific
|
|
1136
|
+
# "no transaction is active" error in its driver; PostgreSQL/MySQL/MSSQL emit
|
|
1137
|
+
# at most a benign warning), so this never raises in the common case. Mirrors
|
|
1138
|
+
# the `not self._in_transaction and self.autocommit` gate in the Python
|
|
1139
|
+
# master and PHP's `autoCommit && transaction === null`.
|
|
1140
|
+
def autocommit_standalone_write(drv)
|
|
1141
|
+
return unless @autocommit
|
|
1142
|
+
return unless Thread.current[@tx_pin_key].nil?
|
|
1143
|
+
|
|
1144
|
+
drv.commit
|
|
1145
|
+
rescue StandardError => e
|
|
1146
|
+
# A standalone write already succeeded; a follow-up commit failure here
|
|
1147
|
+
# must not mask that. Capture for #get_error and log, but don't raise.
|
|
1148
|
+
@last_error = e.message
|
|
1149
|
+
Tina4::Log.warning("autocommit commit after standalone write failed: #{e.message}")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1107
1152
|
# "persistent" / "request" / "off" — mirrors Python connection.py.
|
|
1108
1153
|
def cache_mode
|
|
1109
1154
|
if @cache_persistent
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -312,6 +312,19 @@ module Tina4
|
|
|
312
312
|
@error_tracker ||= ErrorTracker.new
|
|
313
313
|
end
|
|
314
314
|
|
|
315
|
+
# Drop the lazily-memoized dev singletons so the next access rebuilds
|
|
316
|
+
# them from the CURRENT environment. The mailbox in particular resolves
|
|
317
|
+
# its directory from `TINA4_MAILBOX_DIR`/`data/mailbox` at construction
|
|
318
|
+
# time, so a singleton built under one env must not leak into a later
|
|
319
|
+
# caller (or test) running under a different env. Safe to call anytime —
|
|
320
|
+
# it only nils the caches.
|
|
321
|
+
def reset_singletons!
|
|
322
|
+
@message_log = nil
|
|
323
|
+
@request_inspector = nil
|
|
324
|
+
@mailbox = nil
|
|
325
|
+
@error_tracker = nil
|
|
326
|
+
end
|
|
327
|
+
|
|
315
328
|
def enabled?
|
|
316
329
|
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
317
330
|
end
|
|
@@ -637,13 +650,16 @@ module Tina4
|
|
|
637
650
|
body = read_json_body(env) || {}
|
|
638
651
|
json_response(mcp_tool_call(body))
|
|
639
652
|
# JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
|
|
640
|
-
# speak.
|
|
641
|
-
#
|
|
653
|
+
# speak. The dev tools expose powerful ops (DB query, file read/WRITE,
|
|
654
|
+
# route listing), so beyond the dev-toolbar's TINA4_DEBUG check they are
|
|
655
|
+
# gated on Tina4.mcp_enabled? — explicit TINA4_MCP wins on any host, else
|
|
656
|
+
# dev auto-enable is LOCALHOST-ONLY unless TINA4_MCP_REMOTE=true. Not
|
|
657
|
+
# enabled → falls through to the `else` (nil), so RackApp 404s it. They
|
|
642
658
|
# share the default MCP server's tool registry with the REST shim.
|
|
643
659
|
when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
|
|
644
|
-
mcp_jsonrpc(env)
|
|
660
|
+
Tina4.mcp_enabled? ? mcp_jsonrpc(env) : nil
|
|
645
661
|
when ["GET", "/__dev/mcp/sse"]
|
|
646
|
-
mcp_sse_handshake
|
|
662
|
+
Tina4.mcp_enabled? ? mcp_sse_handshake : nil
|
|
647
663
|
when ["GET", "/__dev/api/scaffold"]
|
|
648
664
|
json_response(scaffold_templates)
|
|
649
665
|
when ["POST", "/__dev/api/scaffold/run"]
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -20,8 +20,11 @@ module Tina4
|
|
|
20
20
|
@table_name = name
|
|
21
21
|
else
|
|
22
22
|
base = self.name.split("::").last.downcase
|
|
23
|
-
#
|
|
24
|
-
|
|
23
|
+
# Pluralization is OFF by default (canonical, matching the Python
|
|
24
|
+
# master): the table name is the bare class name lowercased. Opt in by
|
|
25
|
+
# setting TINA4_ORM_PLURAL_TABLE_NAMES to a truthy value (true/1/yes/on)
|
|
26
|
+
# to append "s".
|
|
27
|
+
if ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(true|1|yes|on)\z/i)
|
|
25
28
|
base += "s" unless base.end_with?("s")
|
|
26
29
|
end
|
|
27
30
|
@table_name || base
|
data/lib/tina4/log.rb
CHANGED
|
@@ -12,11 +12,12 @@ module Tina4
|
|
|
12
12
|
"[TINA4_LOG_INFO]" => 1,
|
|
13
13
|
"[TINA4_LOG_WARNING]" => 2,
|
|
14
14
|
"[TINA4_LOG_ERROR]" => 3,
|
|
15
|
-
"[
|
|
15
|
+
"[TINA4_LOG_CRITICAL]" => 4,
|
|
16
|
+
"[TINA4_LOG_NONE]" => 5
|
|
16
17
|
}.freeze
|
|
17
18
|
|
|
18
19
|
SEVERITY_MAP = {
|
|
19
|
-
debug: 0, info: 1, warn: 2, error: 3
|
|
20
|
+
debug: 0, info: 1, warn: 2, error: 3, critical: 4
|
|
20
21
|
}.freeze
|
|
21
22
|
|
|
22
23
|
COLORS = {
|
|
@@ -65,15 +66,36 @@ module Tina4
|
|
|
65
66
|
@format = format_env && !format_env.empty? ? format_env.downcase : (production? ? "json" : "text")
|
|
66
67
|
@json_mode = @format == "json"
|
|
67
68
|
|
|
68
|
-
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
69
|
+
# TINA4_LOG_OUTPUT — "stdout", "file", or "both".
|
|
70
|
+
#
|
|
71
|
+
# Default (UNSET): stdout is ALWAYS on. The log FILE (tina4.log + any
|
|
72
|
+
# error log) is written ONLY in development — i.e. when TINA4_DEBUG is
|
|
73
|
+
# truthy. In production / containers (TINA4_DEBUG falsy) the logger is
|
|
74
|
+
# stdout-only: writing a log file inside a container just bloats the
|
|
75
|
+
# writable layer + disk, and 12-factor wants logs on stdout for the
|
|
76
|
+
# platform to capture. An explicit TINA4_LOG_OUTPUT=file/both (or an
|
|
77
|
+
# explicit TINA4_LOG_FILE path) overrides this and STILL writes a file.
|
|
78
|
+
# Mirrors the Python master (debug/__init__.py configure()).
|
|
79
|
+
# An explicit TINA4_LOG_FILE always wins: a path the operator named must
|
|
80
|
+
# be written even in production (parity with the Python master, where an
|
|
81
|
+
# explicit log_file builds a writer unconditionally), so the dev-gated
|
|
82
|
+
# default below resolves to "both" (stdout + file) rather than "stdout".
|
|
83
|
+
explicit_file = !(log_file_env.nil? || log_file_env.empty?)
|
|
84
|
+
default_output = if explicit_file || truthy?(ENV["TINA4_DEBUG"])
|
|
85
|
+
"both"
|
|
86
|
+
else
|
|
87
|
+
"stdout"
|
|
88
|
+
end
|
|
69
89
|
output_env = ENV["TINA4_LOG_OUTPUT"]
|
|
70
|
-
@output = output_env && !output_env.empty?
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
@output = if output_env && !output_env.empty?
|
|
91
|
+
output_env.downcase
|
|
92
|
+
else
|
|
93
|
+
default_output
|
|
94
|
+
end
|
|
95
|
+
@output = default_output unless %w[stdout file both].include?(@output)
|
|
74
96
|
|
|
75
|
-
#
|
|
76
|
-
@
|
|
97
|
+
# TINA4_LOG_STRICT — when true, raise on log write failures instead of swallowing.
|
|
98
|
+
@strict = truthy?(ENV["TINA4_LOG_STRICT"])
|
|
77
99
|
|
|
78
100
|
@console_level = resolve_level
|
|
79
101
|
@request_id = nil
|
|
@@ -122,6 +144,28 @@ module Tina4
|
|
|
122
144
|
@json_mode
|
|
123
145
|
end
|
|
124
146
|
|
|
147
|
+
# Would a message at `level` pass the configured MINIMUM CONSOLE LEVEL
|
|
148
|
+
# (TINA4_LOG_LEVEL)? Returns true iff `log` would print it to stdout —
|
|
149
|
+
# it reflects CONSOLE visibility only. The log FILE records every level
|
|
150
|
+
# regardless of this threshold, so this never gates file output.
|
|
151
|
+
#
|
|
152
|
+
# `level` accepts a String or Symbol and is case-insensitive
|
|
153
|
+
# ("INFO", :info, "Warning", :warning all work). Mirrors Python's
|
|
154
|
+
# Log.is_enabled. It REUSES the exact severity >= @console_level
|
|
155
|
+
# comparison the console branch in `log` uses (line ~167) via
|
|
156
|
+
# SEVERITY_MAP / resolve_level — it never re-implements level
|
|
157
|
+
# comparison, so it can never disagree with what the logger prints.
|
|
158
|
+
#
|
|
159
|
+
# "critical" is a FIRST-CLASS top-level severity (4 — above error 3),
|
|
160
|
+
# not a parity alias for error. It is evaluated with ordinary threshold
|
|
161
|
+
# logic (critical 4 >= @console_level), so it passes at every level
|
|
162
|
+
# except none (5) — matching the Python master.
|
|
163
|
+
def enabled?(level)
|
|
164
|
+
sym = normalize_level(level)
|
|
165
|
+
severity = SEVERITY_MAP[sym] || 0
|
|
166
|
+
severity >= console_level
|
|
167
|
+
end
|
|
168
|
+
|
|
125
169
|
def info(message, context = {})
|
|
126
170
|
log(:info, message, context)
|
|
127
171
|
end
|
|
@@ -138,6 +182,14 @@ module Tina4
|
|
|
138
182
|
log(:error, message, context)
|
|
139
183
|
end
|
|
140
184
|
|
|
185
|
+
# critical is the HIGHEST severity (4, above error). Like every other
|
|
186
|
+
# level it ALWAYS emits, subject only to the TINA4_LOG_LEVEL threshold
|
|
187
|
+
# (which critical passes at every level except none). A critical log is
|
|
188
|
+
# never a silent no-op. Mirrors the Python master.
|
|
189
|
+
def critical(message, context = {})
|
|
190
|
+
log(:critical, message, context)
|
|
191
|
+
end
|
|
192
|
+
|
|
141
193
|
# Test/teardown helper — closes the underlying Logger so the file
|
|
142
194
|
# handle is released (Windows / tmpdir cleanup).
|
|
143
195
|
def close_file_logger
|
|
@@ -181,6 +233,28 @@ module Tina4
|
|
|
181
233
|
@current_context = {}
|
|
182
234
|
end
|
|
183
235
|
|
|
236
|
+
# The current minimum console level as an integer (the same value
|
|
237
|
+
# the console branch in `log` compares against). Ensures the logger
|
|
238
|
+
# is configured so `enabled?` works before any log call has run.
|
|
239
|
+
def console_level
|
|
240
|
+
configure unless @initialized
|
|
241
|
+
@console_level
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Map a level (String or Symbol, case-insensitive) onto the symbol
|
|
245
|
+
# space used by SEVERITY_MAP. Accepts the public method names
|
|
246
|
+
# (debug/info/warning/error/critical) and the internal :warn symbol.
|
|
247
|
+
# critical is a FIRST-CLASS level (severity 4), not an alias for error.
|
|
248
|
+
# Unknown levels fall through to their own symbol and resolve to
|
|
249
|
+
# severity 0 in `enabled?`.
|
|
250
|
+
def normalize_level(level)
|
|
251
|
+
sym = level.to_s.strip.downcase.to_sym
|
|
252
|
+
case sym
|
|
253
|
+
when :warning then :warn
|
|
254
|
+
else sym
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
184
258
|
def resolve_level
|
|
185
259
|
# v3.13.14: default is INFO (was ALL) so a deployed app surfaces
|
|
186
260
|
# request/startup/warn/error without debug noise, matching
|
|
@@ -199,6 +273,7 @@ module Tina4
|
|
|
199
273
|
when :info then "INFO"
|
|
200
274
|
when :warn then "WARNING"
|
|
201
275
|
when :error then "ERROR"
|
|
276
|
+
when :critical then "CRITICAL"
|
|
202
277
|
else level.to_s.upcase
|
|
203
278
|
end
|
|
204
279
|
end
|
|
@@ -278,6 +353,7 @@ module Tina4
|
|
|
278
353
|
when :info then COLORS[:green]
|
|
279
354
|
when :warn then COLORS[:yellow]
|
|
280
355
|
when :error then COLORS[:red]
|
|
356
|
+
when :critical then COLORS[:magenta]
|
|
281
357
|
else COLORS[:reset]
|
|
282
358
|
end
|
|
283
359
|
"#{color}#{line}#{COLORS[:reset]}"
|
|
@@ -288,7 +364,7 @@ module Tina4
|
|
|
288
364
|
# Use << to bypass Logger's severity filtering — we already filtered above.
|
|
289
365
|
@file_logger << "#{line}\n"
|
|
290
366
|
rescue IOError, SystemCallError => e
|
|
291
|
-
raise if @
|
|
367
|
+
raise if @strict
|
|
292
368
|
# Don't crash on log write failure
|
|
293
369
|
end
|
|
294
370
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -139,15 +139,35 @@ module Tina4
|
|
|
139
139
|
|
|
140
140
|
# Resolve whether the built-in MCP dev server should be active.
|
|
141
141
|
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
142
|
+
# Resolution order (highest priority first):
|
|
143
|
+
# 1. TINA4_MCP set explicitly → use that (truthy/falsey). Honoured on ANY
|
|
144
|
+
# host. An explicit `true` is how a sysadmin opts a remote /
|
|
145
|
+
# debug-disabled deployment in (e.g. for a remote AI assistant); an
|
|
146
|
+
# explicit `false` force-disables it everywhere.
|
|
147
|
+
# 2. TINA4_DEBUG=true → implicit on for dev, but LOCALHOST-ONLY unless
|
|
148
|
+
# TINA4_MCP_REMOTE=true. The MCP dev tools expose powerful operations
|
|
149
|
+
# (DB query, file read/WRITE, route listing), so they never auto-expose
|
|
150
|
+
# on a non-localhost host without an explicit opt-in.
|
|
151
|
+
# 3. Otherwise off.
|
|
152
|
+
#
|
|
153
|
+
# Mirrors the Python master (tina4_python/mcp/__init__.py is_enabled). Before
|
|
154
|
+
# v3.13.39 is_localhost? was dead code and TINA4_MCP_REMOTE was never read, so
|
|
155
|
+
# the documented localhost guard was not actually enforced — a non-localhost
|
|
156
|
+
# TINA4_DEBUG=true deployment auto-exposed the dev tools. This wires it.
|
|
145
157
|
def self.mcp_enabled?
|
|
146
158
|
explicit = ENV["TINA4_MCP"]
|
|
147
159
|
if explicit && !explicit.empty?
|
|
148
|
-
return
|
|
160
|
+
return truthy?(explicit)
|
|
149
161
|
end
|
|
150
|
-
|
|
162
|
+
return false unless truthy?(ENV["TINA4_DEBUG"])
|
|
163
|
+
|
|
164
|
+
# Dev auto-enable: localhost only, unless explicitly opted into remote.
|
|
165
|
+
is_localhost? || truthy?(ENV["TINA4_MCP_REMOTE"])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Case-insensitive truthiness for env values: true/1/yes/on.
|
|
169
|
+
def self.truthy?(val)
|
|
170
|
+
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
151
171
|
end
|
|
152
172
|
|
|
153
173
|
# Resolve the dedicated MCP port. Defaults to (server port + 2000) — keeps
|