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.
Files changed (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # The contract every database driver must satisfy.
5
+ #
6
+ # Feature 3 of the feature audit. Ruby had NO adapter interface: `Database`
7
+ # called four things on a driver and guarded the rest behind six `respond_to?`
8
+ # checks. The consequences, in order of severity:
9
+ #
10
+ # - A driver missing a method was discovered at runtime, on whichever engine
11
+ # nobody exercised, and the guards meant the failure was often a SILENT SKIP
12
+ # rather than an exception - which is worse than a crash.
13
+ # - Nothing told a contributor writing an eighth driver what to implement. The
14
+ # answer was "read database.rb and infer", and it is 828 lines.
15
+ # - The audit could not compare Ruby's contract to the other three, because
16
+ # Ruby did not have one. That was the finding.
17
+ #
18
+ # Measured against the shared contract (spec/fixtures/adapter_contract.json,
19
+ # byte-identical in all four), the seven drivers scored 9, 10 and 11 out of 20
20
+ # - three different levels of completeness, because each implemented whatever
21
+ # its facade path happened to need.
22
+ #
23
+ # Every method here raises. A driver that does not override one fails LOUDLY,
24
+ # at the point of the call, naming itself and the method - instead of being
25
+ # quietly skipped.
26
+ #
27
+ # == Migration in progress
28
+ #
29
+ # The owner's decision (2026-07-30) is that CRUD lives on the ADAPTER, matching
30
+ # PHP, Python and Node. Today Ruby's facade builds the SQL for fetch / insert /
31
+ # update / delete and calls the driver's +execute+, consulting +drv.insert+
32
+ # only when a driver chooses to own it (PostgreSQL does, via RETURNING *).
33
+ # Those methods are declared here so the gap is visible and countable; they are
34
+ # migrated driver by driver, each with its own test, rather than in one sweep.
35
+ #
36
+ # Until a driver overrides them, +Database+ keeps using its own path - see
37
+ # +Database#driver_implements?+, which asks whether the driver actually
38
+ # OVERRODE a method rather than whether it merely responds to it. That
39
+ # distinction is the whole point: including this module makes every driver
40
+ # respond to everything, so +respond_to?+ stopped being able to tell the
41
+ # difference.
42
+ module DatabaseAdapter
43
+ # Methods a driver MUST override. Kept as data so the conformance spec can
44
+ # read it instead of maintaining a second copy of the list.
45
+ # The REDESIGNED contract: only what genuinely differs per engine.
46
+ #
47
+ # CRUD (insert/update/delete), executeMany, fetchOne and DDL
48
+ # (create_table/add_column) are NOT here. They are composable above the
49
+ # adapter from execute + fetch + get_database_type, and Ruby was already
50
+ # doing exactly that in the facade - which is why Ruby's driver layer is
51
+ # 1335 LOC against PHP's 5823 for the same job. The first contract this row
52
+ # produced would have made Ruby write those seven more times; this one keeps
53
+ # the shape Ruby already had and asks the other three to adopt it.
54
+ CONTRACT = %i[
55
+ open close get_database_type
56
+ execute fetch
57
+ start_transaction commit rollback autocommit
58
+ get_tables get_columns table_exists
59
+ last_insert_id error
60
+ ].freeze
61
+
62
+ CONTRACT.each do |name|
63
+ define_method(name) do |*_args, **_kwargs, &_block|
64
+ raise NotImplementedError,
65
+ "#{self.class} does not implement ##{name}, which the Tina4 " \
66
+ "database adapter contract requires. See Tina4::DatabaseAdapter."
67
+ end
68
+ end
69
+
70
+ # == Bounding the connect
71
+ #
72
+ # A connect that can block forever hangs the whole application with NO log,
73
+ # no error and no signal. MEASURED here on Ruby 3.2.3 / Ubuntu 24.04.4
74
+ # against a real TCPServer that accepts the TCP connection and then never
75
+ # replies: pg, mysql2, tiny_tds AND fb all sat past 20 seconds and needed
76
+ # SIGKILL - `timeout`'s SIGTERM could not even be delivered, because the
77
+ # blocking work happens inside a C client that never yields to the
78
+ # interpreter. (A CLOSED port is a different thing entirely: it refuses in
79
+ # 0.00s and tests nothing.)
80
+ #
81
+ # ONE variable governs every driver whose connect crosses a network:
82
+ #
83
+ # TINA4_DATABASE_CONNECT_TIMEOUT seconds, default 10; <= 0 disables the
84
+ # bound (unbounded, the old behaviour);
85
+ # a non-number warns and falls back to 10
86
+ #
87
+ # Each driver applies it through its OWN native option - libpq
88
+ # connect_timeout, mysql2 connect_timeout, FreeTDS login_timeout, mongo
89
+ # connect_timeout - because only the C client can interrupt its own blocking
90
+ # socket work. Ruby's Timeout.timeout and Thread#join CANNOT: see
91
+ # Tina4::Drivers::FirebirdDriver.bound_reachability! for the measurement.
92
+ #
93
+ # There is deliberately NO outer Ruby timeout racing the native one. The
94
+ # native option is the ONLY timer; bounding_connect below merely TRANSLATES
95
+ # whatever the client raises into the one contract message, so the operator
96
+ # is never left holding a driver-worded error that names no variable. Where
97
+ # a driver cannot produce that message at all, its own file says so at the
98
+ # point of exclusion:
99
+ #
100
+ # postgres bounded + contract message libpq connect_timeout
101
+ # mysql bounded + contract message mysql2 connect_timeout
102
+ # mssql bounded + contract message FreeTDS login_timeout
103
+ # firebird bounded to REACHABILITY only stdlib socket; the attach itself
104
+ # cannot be bounded from Ruby
105
+ # mongodb bounded, NO message possible Client.new never fails
106
+ # sqlite n/a local file, no network peer
107
+ # odbc NOT bounded gem untestable here; see its file
108
+ CONNECT_TIMEOUT_VAR = "TINA4_DATABASE_CONNECT_TIMEOUT"
109
+ DEFAULT_CONNECT_TIMEOUT_SECONDS = 10
110
+
111
+ # Clock slack when deciding whether a failed connect was OUR bound expiring.
112
+ # A native bound of 10s is measured back as 9.998s often enough to matter,
113
+ # and without the slack the contract error would degrade into the raw driver
114
+ # error at random.
115
+ CONNECT_TIMEOUT_SLACK_SECONDS = 0.25
116
+
117
+ # Seconds to bound a connect by, or nil when the operator disabled the bound.
118
+ def self.connect_timeout_seconds
119
+ seconds = Tina4::Env.float(CONNECT_TIMEOUT_VAR, default: DEFAULT_CONNECT_TIMEOUT_SECONDS)
120
+ seconds.positive? ? seconds : nil
121
+ end
122
+
123
+ # Whole seconds for the native options that accept only an integer (libpq,
124
+ # libmysqlclient, FreeTDS). Rounds UP and never below 1: libpq reads
125
+ # connect_timeout=0 as "wait forever", so rounding 0.4 DOWN to 0 would
126
+ # silently disable the very bound being set.
127
+ def self.connect_timeout_whole_seconds
128
+ seconds = connect_timeout_seconds
129
+ seconds && [seconds.ceil, 1].max
130
+ end
131
+
132
+ # The one error a timed-out connect raises: it names the host, the port, the
133
+ # seconds actually spent, and the variable that tunes it.
134
+ def self.connect_timed_out!(host, port, elapsed_seconds, cause = nil)
135
+ detail = cause ? " Driver reported: #{cause.message.to_s.gsub(/\s+/, " ").strip}" : ""
136
+ raise Tina4::DatabaseConnectionError,
137
+ "Database connect to #{host}:#{port} timed out after " \
138
+ "#{format("%.1f", elapsed_seconds)}s (#{CONNECT_TIMEOUT_VAR}=" \
139
+ "#{connect_timeout_seconds} seconds; set it to 0 to wait " \
140
+ "indefinitely).#{detail}"
141
+ end
142
+
143
+ # Run a driver's natively-bounded connect and translate an expiry into the
144
+ # contract error above. The NATIVE option does the bounding; this only names
145
+ # it. Whether the bound expired is decided by ELAPSED TIME, not by matching
146
+ # driver error text - the four clients word it four different ways
147
+ # ("timeout expired", "waiting for initial communication packet", "TDS
148
+ # server connection timed out", "Connection timed out"), and a marker table
149
+ # is one more thing to drift and MISS. A missed timeout is the whole defect.
150
+ def self.bounding_connect(host, port)
151
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ yield
153
+ rescue StandardError => error
154
+ bound = connect_timeout_seconds
155
+ raise if bound.nil?
156
+
157
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
158
+ raise if elapsed < bound - CONNECT_TIMEOUT_SLACK_SECONDS
159
+
160
+ connect_timed_out!(host, port, elapsed, error)
161
+ end
162
+
163
+ # Did this driver actually OVERRIDE the contract method, or is it inheriting
164
+ # the raising stub? `respond_to?` cannot answer that once the module is
165
+ # included, and answering it wrongly turns a working silent-skip path into a
166
+ # NotImplementedError at runtime.
167
+ def self.implemented_by?(object, name)
168
+ return false unless object.respond_to?(name)
169
+
170
+ owner = begin
171
+ object.class.instance_method(name).owner
172
+ rescue NameError
173
+ nil
174
+ end
175
+ !owner.nil? && owner != self
176
+ end
177
+ end
178
+ end
@@ -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"