tina4ruby 3.13.94 → 3.13.97

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 +208 -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 +256 -33
  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
@@ -8,7 +8,15 @@ module Tina4
8
8
  attr_reader :records, :columns, :count, :limit, :offset, :sql,
9
9
  :affected_rows, :last_id, :error
10
10
 
11
- def initialize(records = [], sql: "", columns: [], count: nil, limit: 10, offset: 0,
11
+ # `limit` is the row cap ACTUALLY APPLIED to the statement that produced
12
+ # these records — what Database#fetch appended — and `0` means none was
13
+ # (an explicit no-limit read, SQL carrying its own trailing LIMIT, or a
14
+ # write). The default used to be `10`: Database#fetch_direct never passed
15
+ # limit:/offset:, so that untouched default was reported on EVERY fetch
16
+ # whatever limit ran — and 10 is the stale v2 documentation number that the
17
+ # buried PHP row-cap test also asserted as the cap. 0 says "no cap recorded"
18
+ # instead of quietly naming a number nothing applied.
19
+ def initialize(records = [], sql: "", columns: [], count: nil, limit: 0, offset: 0,
12
20
  affected_rows: 0, last_id: nil, error: nil, db: nil)
13
21
  @records = records || []
14
22
  @sql = sql
@@ -91,26 +99,60 @@ module Tina4
91
99
  lines.join("\n")
92
100
  end
93
101
 
94
- def to_paginate(page: nil, per_page: nil)
95
- per_page ||= @limit > 0 ? @limit : 10
96
- page ||= @offset > 0 ? (@offset / per_page) + 1 : 1
97
- total = @count
98
- total_pages = [1, (total.to_f / per_page).ceil].max
99
- slice_offset = (page - 1) * per_page
100
- page_records = @records[slice_offset, per_page] || []
102
+ # Describe the page this result already IS. Takes NO arguments (ADR-0043).
103
+ #
104
+ # Every field is derived from the query that produced this result:
105
+ # per_page = the query's limit
106
+ # page = floor(offset / limit) + 1
107
+ # total = the true total for the filter (@count, from the COUNT probe
108
+ # Database#fetch runs when it applied the limit), NEVER the
109
+ # number of rows this page returned
110
+ # total_pages = ceil(total / per_page)
111
+ # records = the rows the query returned, VERBATIM, never re-sliced
112
+ # limit/offset= the SQL limit/offset actually applied
113
+ #
114
+ # The envelope is EXACTLY seven snake_case keys, identical in all four
115
+ # frameworks: records, total, page, per_page, total_pages, limit, offset. The
116
+ # old duplicate/camelCase spellings (data, count, totalPages, has_next,
117
+ # has_prev) are gone - a JSON key is data and does not change spelling by host
118
+ # language, so the same integer never ships twice under two names.
119
+ #
120
+ # PASSING ANY ARGUMENT RAISES. A DatabaseResult holds no connection, so an
121
+ # argument could only re-slice rows already in memory and then report
122
+ # total_pages for pages it can never reach. To read page N, FETCH page N
123
+ # (limit + offset) and call this with no arguments. The removed page:/per_page:
124
+ # slicing mode is a hard error, never a silent reinterpretation - superseding
125
+ # the in-memory slice GitHub #106 asked for.
126
+ #
127
+ # MEASURED 2026-08-05 on a real 250-row table read with limit=20 offset=40
128
+ # (page 3 of 13): the old two-mode method re-sliced @records by the ABSOLUTE
129
+ # offset (40) against an array already only 20 long and returned ZERO records
130
+ # for a valid page, while still shipping a page number and total - an envelope
131
+ # that looked authoritative and was empty.
132
+ def to_paginate(*args, **kwargs)
133
+ unless args.empty? && kwargs.empty?
134
+ raise ArgumentError,
135
+ "to_paginate takes no arguments (ADR-0043): it describes the page " \
136
+ "this result already IS, derived from the query that produced it. A " \
137
+ "DatabaseResult holds no connection, so an argument could only " \
138
+ "re-slice rows already in memory and lie about total_pages. To read " \
139
+ "another page, FETCH it - fetch(sql, limit: per_page, offset: " \
140
+ "(page - 1) * per_page) - then call to_paginate with no arguments."
141
+ end
142
+
143
+ per_page = @limit.to_i > 0 ? @limit.to_i : @records.size
144
+ page = per_page > 0 ? (@offset.to_i / per_page) + 1 : 1
145
+ total = @count
146
+ total_pages = per_page > 0 ? [1, (total.to_f / per_page).ceil].max : 1
147
+
101
148
  {
102
- records: page_records,
103
- data: page_records,
104
- count: total,
149
+ records: @records,
105
150
  total: total,
106
- limit: per_page,
107
- offset: (page - 1) * per_page,
108
151
  page: page,
109
152
  per_page: per_page,
110
- totalPages: total_pages,
111
153
  total_pages: total_pages,
112
- has_next: page < total_pages,
113
- has_prev: page > 1
154
+ limit: per_page,
155
+ offset: @offset.to_i
114
156
  }
115
157
  end
116
158
 
@@ -179,7 +221,11 @@ module Tina4
179
221
  size: size,
180
222
  decimals: decimals,
181
223
  nullable: col.key?(:nullable) ? col[:nullable] : (col.key?("nullable") ? col["nullable"] : true),
182
- primary_key: col[:primary_key] || col["primary_key"] || col[:primary] || col["primary"] || false
224
+ # Symbol OR string key, because a driver row may arrive either way.
225
+ # The `:primary` / `"primary"` fallbacks that used to sit here were
226
+ # dead: no Ruby driver has ever emitted that spelling. It was mirroring
227
+ # PHP's odd `primary`, which PHP itself has now dropped for `primaryKey`.
228
+ primary_key: col[:primary_key] || col["primary_key"] || false
183
229
  }
184
230
  end
185
231
  end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Tina4
6
+ # A parsed database connection URL, as a VALUE.
7
+ #
8
+ # Feature 5 of the feature audit. Ruby had no parser to call: URL handling was
9
+ # inline in +Database#initialize+, so a URL could not be parsed without
10
+ # building a connection object. The parse could not be unit tested on its own,
11
+ # the four frameworks could not be compared without standing up a database,
12
+ # and +tina4 doctor+ or the setup wizard had nothing to call to validate a URL
13
+ # before using it.
14
+ #
15
+ # What was there instead was +detect_driver+: substring regex matching over the
16
+ # whole connection string with a silent <tt>else "sqlite"</tt> fallback, so an
17
+ # unrecognised URL did not fail - it quietly became SQLite. The app boots,
18
+ # writes to a local file, and nobody learns the real database was never
19
+ # reached. This class raises instead.
20
+ #
21
+ # Core Principle 6 says a connection string must mean literally the same thing
22
+ # in every framework. +spec/fixtures/database_url_corpus.json+ is the answer
23
+ # key, byte-identical in all four.
24
+ # DISPLAY REDACTS, FIDELITY DOES NOT. #inspect, #to_s and #to_safe_string
25
+ # replace the password with the redaction marker, so a log line, a backtrace or
26
+ # a status payload is safe. Marshal deliberately does not: its contract is a
27
+ # faithful round trip, and a masked Marshal would load an object whose password
28
+ # is the literal "***".
29
+ #
30
+ # The consequence: DO NOT PERSIST THIS OBJECT. A DatabaseUrl marshalled into a
31
+ # cache, a session or a queue payload puts the password in cleartext on disk.
32
+ # Record #to_safe_string instead. spec/database_url_redaction_spec.rb fails the
33
+ # build if framework code ever marshals one.
34
+ class DatabaseUrl
35
+ # URL scheme to CANONICAL engine. Aliases resolve ONCE, here, so nothing
36
+ # downstream ever compares raw schemes.
37
+ #
38
+ # +sqlite3+ is accepted because the driver is literally named sqlite3 in
39
+ # every framework (Python's sqlite3 module, Ruby's sqlite3 gem, PHP's
40
+ # ext-sqlite3, Node's node:sqlite), so people type it. The "3" is a
41
+ # file-format version, not a different engine, which is why the canonical
42
+ # name stays +sqlite+.
43
+ ENGINE_ALIASES = {
44
+ "sqlite" => "sqlite",
45
+ "sqlite3" => "sqlite",
46
+ "postgres" => "postgres",
47
+ "postgresql" => "postgres",
48
+ "pgsql" => "postgres",
49
+ "mysql" => "mysql",
50
+ "mssql" => "mssql",
51
+ "sqlserver" => "mssql",
52
+ "firebird" => "firebird",
53
+ "mongodb" => "mongodb",
54
+ "mongo" => "mongodb",
55
+ "odbc" => "odbc"
56
+ }.freeze
57
+
58
+ # Applied AT PARSE. The port is part of our contract, not the driver's
59
+ # business: a URL with no port must yield the same struct in all four.
60
+ DEFAULT_PORTS = {
61
+ "postgres" => 5432,
62
+ "mysql" => 3306,
63
+ "mssql" => 1433,
64
+ "firebird" => 3050,
65
+ "mongodb" => 27017
66
+ }.freeze
67
+
68
+ # What replaces a credential everywhere. One spelling, so a test can assert
69
+ # on it and a reader recognises it on sight.
70
+ REDACTED = "***"
71
+
72
+ # RFC 3986 scheme grammar. A scheme cannot contain ":", "@", "/" or a space,
73
+ # which is what makes it the ONLY part of an unparseable string that is
74
+ # provably not a password.
75
+ SCHEME = /\A[A-Za-z][A-Za-z0-9+.\-]*\z/.freeze
76
+
77
+ # host[:port][/path] with a NUMERIC port. The digit requirement is what does
78
+ # the work: it is why "user:secret" (a URL with the host left off) fails to
79
+ # match and gets redacted, while "192.168.88.99:55432/tina4_py" is echoed.
80
+ HOST_PORT = %r{\A(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::\d+)?(?:/.*)?\z}m.freeze
81
+
82
+ # Credential KEYWORDS in the two keyword=value forms that carry one: the
83
+ # ODBC connection string (";"-separated) and the libpq/JDBC query string
84
+ # ("&"-separated). A braced value is matched FIRST because an ODBC value
85
+ # containing ";" must be braced - stopping at the ";" would leave the tail
86
+ # of the password in the output, which is exactly the bug shape this file
87
+ # exists to close.
88
+ #
89
+ # UID/USER are deliberately NOT redacted: a username is not a secret, and it
90
+ # is most of what makes a failed connection diagnosable.
91
+ CREDENTIAL_KEYWORDS = /\b(PWD|PASSWORD)\s*=\s*(?:\{[^}]*\}|[^;&]*)/i.freeze
92
+
93
+ attr_reader :engine, :host, :port, :database, :username, :password,
94
+ :connection_string
95
+
96
+ def initialize(url, username: nil, password: nil)
97
+ raise ArgumentError, "DatabaseUrl: the URL is empty" if url.nil? || url.to_s.strip.empty?
98
+
99
+ url = url.to_s
100
+ @host = nil
101
+ @port = nil
102
+ @database = ""
103
+ @username = nil
104
+ @password = nil
105
+ @connection_string = nil
106
+
107
+ if url.start_with?("sqlite:", "sqlite3:")
108
+ parse_sqlite(url)
109
+ elsif url.start_with?("odbc:///")
110
+ @engine = "odbc"
111
+ @connection_string = url[("odbc:///".length)..]
112
+ else
113
+ parse_standard(url)
114
+ end
115
+
116
+ # Separate credentials fill in only when the URL carried NONE.
117
+ #
118
+ # ABSENT and BLANK are different values, and the difference is load
119
+ # bearing: the gate is `nil?`, never `empty?`. A URL written
120
+ # postgres://user:@host/db
121
+ # states an explicitly EMPTY password, so the TINA4_DATABASE_PASSWORD
122
+ # fallback must NOT fire; only
123
+ # postgres://user@host/db
124
+ # is absent and takes the env value. Measured 2026-08-02: Ruby and PHP
125
+ # obey this, Python and Node return None for the blank form and therefore
126
+ # authenticate with a DIFFERENT password off the SAME .env - which is the
127
+ # kind of divergence nobody notices until an account locks out.
128
+ @username = username if @username.nil? && username && !username.empty?
129
+ @password = password if @password.nil? && password && !password.empty?
130
+ end
131
+
132
+ # Parse the configured URL, or nil when the variable is not set.
133
+ def self.from_env(key = "TINA4_DATABASE_URL")
134
+ url = (ENV[key] || "").strip
135
+ return nil if url.empty?
136
+
137
+ new(url, username: ENV["TINA4_DATABASE_USERNAME"], password: ENV["TINA4_DATABASE_PASSWORD"])
138
+ end
139
+
140
+ # THE redaction primitive. Every connection string that is about to reach a
141
+ # log line, an exception message or a dump goes through here.
142
+ #
143
+ # It takes a RAW string and never raises, because the paths that most need
144
+ # redacting are exactly the ones where parsing already failed.
145
+ #
146
+ # Measured in this repo on 2026-08-02, before this existed:
147
+ # * Database#safe_connection_target carried its OWN regex, and
148
+ # postgres://u:p@ss@h:5432/db -> postgres://u:***@ss@h:5432/db
149
+ # leaked the password tail past the first "@" (the Ruby twin of the PHP
150
+ # `password=\S*` tail leak), and
151
+ # odbc:///...;PWD=<secret> -> returned VERBATIM
152
+ # went straight into DatabaseConnectionError, which is raised at the
153
+ # first query and lands in a 500 body.
154
+ # * DatabaseUrl's own invalid-URL ArgumentError interpolated the whole raw
155
+ # URL, and detect_driver raises it on the BOOT path, so one typo in
156
+ # TINA4_DATABASE_URL wrote the password into the boot log and CI output.
157
+ # One primitive means the next such bug is fixed once, not four times.
158
+ def self.redact(raw)
159
+ text = raw.to_s
160
+ return "" if text.empty?
161
+
162
+ begin
163
+ # A URL that PARSES gets the round-tripping form rebuilt from the parsed
164
+ # FIELDS. That form cannot leak: the password is never copied into it.
165
+ new(text).to_safe_string
166
+ rescue StandardError
167
+ # Unparseable - fall back to the structural scrub. Redaction must never
168
+ # raise; a raise here would mask the error we were called to describe.
169
+ scrub(text)
170
+ end
171
+ end
172
+
173
+ # Structural redaction of a string that did NOT parse.
174
+ def self.scrub(text)
175
+ head, separator, rest = text.to_s.partition("://")
176
+
177
+ # No "://" - a bare file path, or an ODBC/keyword string. There is no
178
+ # userinfo to find, so only the keyword form can hide a secret here.
179
+ return scrub_keywords(text.to_s) if separator.empty?
180
+
181
+ # A head that is not a scheme is unknown text, and unknown text may BE the
182
+ # secret (the measured Python case was `notaurl-with-SuperSecret123`).
183
+ # Say nothing about it rather than guess.
184
+ return REDACTED unless head.match?(SCHEME)
185
+
186
+ # The authority is taken to the first "?" or "#", NOT to the first "/".
187
+ # An unencoded "/" inside a password would otherwise cut the region short,
188
+ # push the "@" outside it, and let the password prefix survive.
189
+ cut = rest.index(/[?#]/) || rest.length
190
+ "#{head}://#{redact_authority(rest[0, cut])}#{scrub_keywords(rest[cut..].to_s)}"
191
+ end
192
+
193
+ # Take the credential out of a URL authority.
194
+ #
195
+ # user:pass@host:5432/db -> user:***@host:5432/db
196
+ # host:5432/db -> host:5432/db (no credential present)
197
+ # user:pass -> user:*** (host left off - a real typo,
198
+ # and the only reason the no-"@"
199
+ # branch cannot echo blindly)
200
+ def self.redact_authority(authority)
201
+ # The LAST "@", because userinfo ends at the last "@" (RFC 3986). Splitting
202
+ # on the FIRST one is how an unencoded "@" inside a password leaves a tail
203
+ # in the output.
204
+ at = authority.rindex("@")
205
+ if at
206
+ userinfo = authority[0, at]
207
+ host = authority[(at + 1)..].to_s
208
+ name, colon, = userinfo.partition(":")
209
+ return colon.empty? ? "#{name}@#{host}" : "#{name}:#{REDACTED}@#{host}"
210
+ end
211
+
212
+ return authority if authority.match?(HOST_PORT)
213
+
214
+ name, colon, = authority.partition(":")
215
+ colon.empty? ? name : "#{name}:#{REDACTED}"
216
+ end
217
+
218
+ # Redact the value side of a credential keyword, leaving every other
219
+ # keyword visible so the string is still diagnosable.
220
+ def self.scrub_keywords(text)
221
+ text.gsub(CREDENTIAL_KEYWORDS) { "#{Regexp.last_match(1)}=#{REDACTED}" }
222
+ end
223
+
224
+ # The invalid-URL message, with the credential taken OUT.
225
+ #
226
+ # It still has to be diagnosable, so it keeps the scheme, the host, the port
227
+ # as written (the usual fault) and the database name - only the password
228
+ # becomes ***. When the value has no "://" there is no structure to trust,
229
+ # so the value is not shown at all and the message says why.
230
+ def self.invalid_url_error(url)
231
+ text = url.to_s
232
+ if text.include?("://")
233
+ "DatabaseUrl: Invalid URL format '#{scrub(text)}' - expected " \
234
+ "scheme://user:password@host:port/database"
235
+ else
236
+ "DatabaseUrl: Invalid URL format - no '://', so there is no scheme to " \
237
+ "connect with. The URL itself is not shown because it can contain a " \
238
+ "password. Expected scheme://user:password@host:port/database"
239
+ end
240
+ end
241
+
242
+ # Connection target. sqlite and odbc are the whole value.
243
+ def dsn
244
+ return @database if @engine == "sqlite"
245
+ return @connection_string.to_s if @engine == "odbc"
246
+
247
+ out = @host.to_s
248
+ out += ":#{@port}" unless @port.nil?
249
+ out += "/#{@database}" unless @database.empty?
250
+ out
251
+ end
252
+
253
+ # The URL with the password replaced by <tt>***</tt>.
254
+ #
255
+ # The ONLY form allowed in a log line or an error message: a connection URL
256
+ # in a log is a credential leak. It round-trips the input, so it stays
257
+ # readable as well as safe.
258
+ def to_safe_string
259
+ return "sqlite:///#{@database}" if @engine == "sqlite"
260
+ # ODBC used to return the connection string VERBATIM here, PWD= included -
261
+ # measured 2026-08-02. The negative test "never leaks the password" passed
262
+ # anyway, because the shared corpus had no odbc row for it to check: the
263
+ # guard existed, the test was green, and it protected nothing. Every other
264
+ # keyword stays visible so a failed ODBC connection is still diagnosable.
265
+ return "odbc:///#{self.class.scrub_keywords(@connection_string.to_s)}" if @engine == "odbc"
266
+
267
+ out = "#{@engine}://"
268
+ unless @username.nil?
269
+ out += @username
270
+ out += ":***" unless @password.nil?
271
+ out += "@"
272
+ end
273
+ out += @host.to_s
274
+ out += ":#{@port}" unless @port.nil?
275
+ out += "/#{@database}" unless @database.empty?
276
+ out
277
+ end
278
+
279
+ # inspect lands in backtraces and the console, so it MUST be the safe form.
280
+ def inspect
281
+ "#<Tina4::DatabaseUrl #{to_safe_string}>"
282
+ end
283
+
284
+ # Interpolation ("connecting to #{url}") and JSON both have to be safe, not
285
+ # just inspect. Ruby's inspect already guarded the console and backtraces -
286
+ # these two close the same class of leak PHP has through print_r/var_dump
287
+ # and Node through JSON.stringify, and they make the useless default
288
+ # "#<Tina4::DatabaseUrl:0x000...>" say something instead.
289
+ def to_s
290
+ to_safe_string
291
+ end
292
+
293
+ def to_json(*args)
294
+ to_safe_string.to_json(*args)
295
+ end
296
+
297
+ private
298
+
299
+ # Strip EXACTLY ONE leading slash: the URL path separator, never more.
300
+ def strip_one_slash(path)
301
+ path.start_with?("/") ? path[1..] : path
302
+ end
303
+
304
+ # sqlite is parsed on the RAW string, never through URI. URI collapses
305
+ # "sqlite:/x" and "sqlite:///x", losing the difference between a one-slash
306
+ # ABSOLUTE path and the documented three-slash RELATIVE form.
307
+ #
308
+ # sqlite:///app.db -> app.db (three slashes = relative)
309
+ # sqlite:////abs/app.db -> /abs/app.db (four slashes = absolute)
310
+ # sqlite:/abs/app.db -> /abs/app.db (one slash = a real absolute)
311
+ # sqlite:app.db -> app.db
312
+ def parse_sqlite(url)
313
+ @engine = "sqlite"
314
+ url = "sqlite:#{url[("sqlite3:".length)..]}" if url.start_with?("sqlite3:")
315
+
316
+ @database = if ["sqlite::memory:", "sqlite:///:memory:"].include?(url)
317
+ ":memory:"
318
+ elsif url.start_with?("sqlite:///")
319
+ strip_one_slash(url[("sqlite://".length)..])
320
+ elsif url.start_with?("sqlite://")
321
+ url[("sqlite://".length)..]
322
+ else
323
+ url[("sqlite:".length)..]
324
+ end
325
+ end
326
+
327
+ def parse_standard(url)
328
+ parsed = begin
329
+ URI.parse(url)
330
+ rescue URI::InvalidURIError
331
+ # NEVER interpolate the raw URL here. Measured 2026-08-02: a malformed
332
+ # TINA4_DATABASE_URL put the password verbatim into this ArgumentError,
333
+ # and Database#detect_driver raises it during boot - so the credential
334
+ # reached the boot log, the error overlay, a crash report and CI output.
335
+ raise ArgumentError, self.class.invalid_url_error(url)
336
+ end
337
+
338
+ scheme = parsed.scheme.to_s.downcase
339
+ raise ArgumentError, self.class.invalid_url_error(url) if scheme.empty?
340
+
341
+ engine = ENGINE_ALIASES[scheme]
342
+ if engine.nil?
343
+ raise ArgumentError,
344
+ "DatabaseUrl: Unsupported database scheme '#{scheme}'. " \
345
+ "Supported: #{ENGINE_ALIASES.keys.join(', ')}"
346
+ end
347
+
348
+ @engine = engine
349
+ @host = parsed.host && !parsed.host.empty? ? parsed.host : nil
350
+ @port = parsed.port || DEFAULT_PORTS[engine]
351
+ @username = parsed.user ? URI.decode_www_form_component(parsed.user) : nil
352
+ @password = parsed.password ? URI.decode_www_form_component(parsed.password) : nil
353
+
354
+ # Strip EXACTLY ONE leading slash - the URL path separator. Stripping every
355
+ # slash turns the documented absolute Firebird form
356
+ # `firebird://host:3050//var/lib/db.fdb` into the RELATIVE
357
+ # `var/lib/db.fdb`. Verified against live Firebird 5.0.4: the driver takes
358
+ # one or two leading slashes and rejects a relative path outright.
359
+ database = strip_one_slash(parsed.path.to_s)
360
+ @database = database.empty? && engine == "mongodb" ? "tina4" : database
361
+ end
362
+ end
363
+ end
data/lib/tina4/dev.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  # Convenience require for all development/optional tools.
4
4
  # Usage: require "tina4/dev"
5
5
 
6
- require_relative "scss_compiler"
7
6
  require_relative "testing"
8
7
  require_relative "graphql"
9
8
  require_relative "websocket"
@@ -467,18 +467,13 @@ module Tina4
467
467
  when ["GET", "/__dev/api/system"]
468
468
  json_response(system_payload)
469
469
  when ["GET", "/__dev/api/queue/topics"]
470
- queue_dir = File.join(Dir.pwd, "data", "queue")
471
- topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
472
- topics = ["default"] if topics.empty?
473
- json_response({ topics: topics })
470
+ json_response({ topics: queue_topics })
474
471
  when ["GET", "/__dev/api/queue/dead-letters"]
475
472
  topic = query_param(env, "topic") || "default"
476
- jobs = []
477
- begin
478
- queue = Tina4::Queue.new(backend: :file, topic: topic) if defined?(Tina4::Queue)
479
- jobs = queue.respond_to?(:dead_letters) ? queue.dead_letters.map { |j| j.merge(status: "dead_letter") } : []
480
- rescue StandardError => e
481
- jobs = []
473
+ jobs = begin
474
+ queue_dead_letters(topic)
475
+ rescue StandardError
476
+ []
482
477
  end
483
478
  json_response({ jobs: jobs, count: jobs.size, topic: topic })
484
479
  when ["GET", "/__dev/api/queue"]
@@ -492,15 +487,14 @@ module Tina4
492
487
  # positionally raises ArgumentError, which the rescue below
493
488
  # swallows — silently zeroing every stat. Use keyword form.
494
489
  stats = {
495
- pending: queue.respond_to?(:size) ? queue.size(status: "pending") : 0,
496
- completed: queue.respond_to?(:size) ? queue.size(status: "completed") : 0,
497
- failed: queue.respond_to?(:size) ? queue.size(status: "failed") : 0,
498
- reserved: queue.respond_to?(:size) ? queue.size(status: "reserved") : 0,
490
+ pending: queue.size(status: "pending"),
491
+ completed: queue.size(status: "completed"),
492
+ failed: queue.size(status: "failed"),
493
+ reserved: queue.size(status: "reserved"),
499
494
  }
500
- jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
501
- jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
495
+ jobs = queue_jobs(topic, query_param(env, "status"))
502
496
  end
503
- rescue StandardError => e
497
+ rescue StandardError
504
498
  # fall through to empty stats
505
499
  end
506
500
  json_response({ jobs: jobs, stats: stats })
@@ -631,10 +625,25 @@ module Tina4
631
625
  when ["GET", "/__dev/api/metrics"]
632
626
  json_response(Tina4::Metrics.quick_metrics)
633
627
  when ["GET", "/__dev/api/metrics/full"]
634
- json_response(Tina4::Metrics.full_analysis)
628
+ # No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
629
+ # install command, never zeros that read as a healthy codebase.
630
+ begin
631
+ json_response(Tina4::Metrics.full_analysis)
632
+ rescue Tina4::MetricsEngineError => e
633
+ json_response({ "error" => e.message }, 503)
634
+ end
635
635
  when ["GET", "/__dev/api/metrics/file"]
636
636
  file_path = (query_param(env, "path") || "").to_s
637
- json_response(Tina4::Metrics.file_detail(file_path))
637
+ begin
638
+ json_response(Tina4::Metrics.file_detail(file_path))
639
+ rescue Tina4::MetricsEngineError => e
640
+ # A bad path is the caller's mistake (404); anything else is the
641
+ # engine being unavailable (503).
642
+ bad_path = e.message.include?("no such file") ||
643
+ e.message.include?("not a file") ||
644
+ e.message.include?("needs a path")
645
+ json_response({ "error" => e.message }, bad_path ? 404 : 503)
646
+ end
638
647
  when ["GET", "/__dev/api/thoughts"]
639
648
  json_response(thoughts_payload)
640
649
  when ["POST", "/__dev/api/supervise/create"]
@@ -805,7 +814,16 @@ module Tina4
805
814
  platform: RUBY_PLATFORM,
806
815
  debug: ENV["TINA4_DEBUG"] || "false",
807
816
  log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
808
- database: ENV["TINA4_DATABASE_URL"] || "not configured",
817
+ # REDACTED, not raw. Measured 2026-08-02: this served
818
+ # TINA4_DATABASE_URL verbatim, so GET /__dev/api/status answered any
819
+ # caller that could reach the debug port with the database password.
820
+ # It is a display field - nothing round-trips it back - so the safe
821
+ # form is strictly better here. (The connections EDITOR at
822
+ # /__dev/api/connections still returns the raw URL on purpose: it
823
+ # populates a form that is saved back to .env, and redacting it there
824
+ # would write *** into the file. That one needs an unchanged-sentinel
825
+ # design, in all four frameworks.)
826
+ database: (ENV["TINA4_DATABASE_URL"].to_s.empty? ? "not configured" : Tina4::DatabaseUrl.redact(ENV["TINA4_DATABASE_URL"])),
809
827
  db_tables: db_table_count,
810
828
  uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
811
829
  route_count: Tina4::Router.routes.size,
@@ -1053,6 +1071,86 @@ module Tina4
1053
1071
  { ok: false, error: e.message }
1054
1072
  end
1055
1073
 
1074
+ # ── Queue panel: the list must describe the set the stats count ──
1075
+ #
1076
+ # Every reader below goes through Tina4::Queue.base_path / .topic_path,
1077
+ # or through the backend itself — the SAME answers the lite backend
1078
+ # writes to and Queue#size counts. These handlers used to re-derive the
1079
+ # path as
1080
+ # Dir.pwd/data/queue, which no Ruby app ever wrote to, so the topic list
1081
+ # could not name a real topic and the job list described a store that was
1082
+ # not the one being counted.
1083
+ #
1084
+ # Each job appears EXACTLY ONCE, in the bucket its OWN stat counts it in:
1085
+ #
1086
+ # stats.pending <- <base>/<topic>/*.queue-data
1087
+ # stats.reserved <- <base>/<topic>/reserved/*.queue-data
1088
+ # stats.failed <- <base>/dead_letter/*.queue-data tagged with topic
1089
+ # stats.completed <- always 0; the file backend deletes on complete
1090
+ #
1091
+ # so sum(stats) == jobs.length by construction on a quiescent store, and
1092
+ # every ?status= filter returns exactly what its own stat counts.
1093
+
1094
+ # Topic directories in the real store. The shared dead-letter directory
1095
+ # is a sibling of the topic directories, not a topic.
1096
+ def queue_topics
1097
+ base = Tina4::Queue.base_path
1098
+ return ["default"] unless Dir.exist?(base)
1099
+
1100
+ topics = Dir.children(base)
1101
+ .select { |entry| File.directory?(File.join(base, entry)) }
1102
+ .reject { |entry| entry == Tina4::Queue::DEAD_LETTER_DIRNAME }
1103
+ .sort
1104
+ topics.empty? ? ["default"] : topics
1105
+ rescue StandardError
1106
+ ["default"]
1107
+ end
1108
+
1109
+ # Jobs for +topic+, optionally narrowed to one bucket.
1110
+ def queue_jobs(topic, status_filter = nil)
1111
+ wanted = status_filter.to_s.strip
1112
+ topic_dir = Tina4::Queue.topic_path(topic)
1113
+ jobs = []
1114
+ jobs.concat(read_queue_dir(topic_dir, "pending")) if wanted.empty? || wanted == "pending"
1115
+ if wanted.empty? || wanted == "reserved"
1116
+ jobs.concat(read_queue_dir(File.join(topic_dir, "reserved"), "reserved"))
1117
+ end
1118
+ jobs.concat(queue_dead_letters(topic)) if wanted.empty? || wanted == "failed" || wanted == "dead"
1119
+ jobs
1120
+ end
1121
+
1122
+ # Read one queue directory the way Queue#size COUNTS it. size globs the
1123
+ # files without parsing them, so a job file that cannot be parsed still
1124
+ # counts — and must therefore still be LISTED, or the panel shows a total
1125
+ # it cannot account for. Listing it is also the only way an operator ever
1126
+ # learns the file is there.
1127
+ def read_queue_dir(dir, status)
1128
+ return [] unless Dir.exist?(dir)
1129
+
1130
+ Tina4::Queue.job_files(dir).filter_map do |file|
1131
+ begin
1132
+ JSON.parse(File.read(file)).merge("status" => status)
1133
+ rescue JSON::ParserError
1134
+ { "id" => File.basename(file, Tina4::Queue::JOB_EXTENSION), "status" => status,
1135
+ "error" => "unreadable job file" }
1136
+ rescue Errno::ENOENT
1137
+ nil # consumed between the glob and the read
1138
+ end
1139
+ end
1140
+ end
1141
+
1142
+ # Every dead letter for +topic+ — the same set stats.failed counts.
1143
+ #
1144
+ # max_retries: 0 is the established "no attempt-count filter" spelling
1145
+ # (the `tina4ruby queue retry` command uses it for the same reason). The
1146
+ # DEFAULT filters on the max_retries of the Queue this dev admin happened
1147
+ # to construct, which is 3 and has nothing to do with the app's: an app
1148
+ # configured max_retries: 1 dead-letters a job at ONE attempt, and every
1149
+ # one of those was counted by stats.failed and never listed.
1150
+ def queue_dead_letters(topic)
1151
+ dev_queue(topic).dead_letters(max_retries: 0).map { |job| job.merge("status" => "dead_letter") }
1152
+ end
1153
+
1056
1154
  # ── Queue run-chips (Tier 3) ───────────────────────────────────
1057
1155
 
1058
1156
  # A file-backed dev Queue for +topic+ (matches the GET /queue handler).