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
data/README.md CHANGED
@@ -58,7 +58,7 @@ db = Tina4::Database.new("sqlite://app.db")
58
58
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
59
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
- | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
61
+ | **Auth & Security** (5) | JWT (HS256/HS384/HS512 standard, RS256 opt-in, both stdlib OpenSSL, no gem), 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
64
  | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
data/lib/tina4/auth.rb CHANGED
@@ -33,6 +33,28 @@ module Tina4
33
33
  "HS512" => OpenSSL::Digest::SHA512
34
34
  }.freeze
35
35
 
36
+ # RS256 (RSA-SHA256) is the OPT-IN asymmetric algorithm, and Ruby needs NO gem
37
+ # for it: OpenSSL is a stdlib DEFAULT GEM, so OpenSSL::PKey::RSA#sign/#verify
38
+ # emit and check exactly the RFC 7515 signature the other frameworks do
39
+ # (measured: a token minted here verifies under PHP openssl_verify AND Node
40
+ # crypto.createVerify, and a tampered payload goes INVALID in both). The `jwt`
41
+ # gem was declared only to wrap the base64url envelope this file already
42
+ # builds for HMAC, so it was dropped.
43
+ #
44
+ # The contract requires a LOUD, ACTIONABLE failure wherever RS256 is
45
+ # unavailable. In Ruby that branch is UNREACHABLE in practice: the
46
+ # `require "openssl"` at the top of this file is what supplies OpenSSL::HMAC
47
+ # for the HMAC path too, so an interpreter lacking it cannot load Tina4 at
48
+ # all. The message therefore names the real remedy and deliberately does NOT
49
+ # suggest installing a library — there is none to install.
50
+ RS256_UNAVAILABLE_MESSAGE =
51
+ "RS256 is unavailable: this Ruby has no OpenSSL::PKey::RSA. OpenSSL is a " \
52
+ "Ruby stdlib DEFAULT GEM, so this interpreter was built without the openssl " \
53
+ "extension — rebuild Ruby with OpenSSL support, or use a build that ships " \
54
+ "it. Do NOT install a JWT gem: Tina4 signs and verifies RS256 with stdlib " \
55
+ "OpenSSL and needs no third-party library. Tina4's standard algorithms are " \
56
+ "HS256/HS384/HS512, which are stdlib OpenSSL too."
57
+
36
58
  # Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without
37
59
  # this a token minted on one host and validated on another a second behind is
38
60
  # rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
@@ -155,75 +177,114 @@ module Tina4
155
177
  OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
156
178
  end
157
179
 
158
- # Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the
159
- # explicit argument, else TINA4_JWT_ALGORITHM, else HS256 and the header
160
- # advertises exactly the algorithm that signed.
161
- def hmac_encode(claims, secret, algorithm: nil)
162
- alg = resolve_algorithm(algorithm)
163
- header = { "alg" => alg, "typ" => "JWT" }
180
+ # Write the base64url header.payload.signature envelope. The signature bytes
181
+ # come from the block, so the HMAC and RS256 paths share ONE envelope writer
182
+ # and one place where "alg" is stamped from whatever actually signed.
183
+ def encode_envelope(algorithm, claims)
164
184
  segments = [
165
- base64url_encode(JSON.generate(header)),
185
+ base64url_encode(JSON.generate({ "alg" => algorithm, "typ" => "JWT" })),
166
186
  base64url_encode(JSON.generate(claims))
167
187
  ]
168
- signing_input = segments.join(".")
169
- segments << base64url_encode(hmac_signature(alg, secret, signing_input))
188
+ segments << base64url_encode(yield(segments.join(".")))
170
189
  segments.join(".")
171
190
  end
172
191
 
173
- # Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
192
+ # Read the envelope, PIN the header alg, check the signature via the block,
193
+ # then apply the RFC 7519 claim rules. Returns the payload hash or nil.
174
194
  #
175
195
  # The algorithm is PINNED to our configured one rather than trusted from the
176
196
  # token: a header asking to be verified as anything else — "none", a
177
- # different HMAC, or an RSA alg we do not implement here — is rejected before
178
- # any signature work.
179
- def hmac_decode(token, secret, algorithm: nil)
180
- # Resolved OUTSIDE the rescue below: an unsupported algorithm is a
181
- # configuration error that must surface, not become a nil "invalid token".
182
- alg = resolve_algorithm(algorithm)
197
+ # different HMAC, or RS256 while we are configured for HMAC — is rejected
198
+ # before any signature work. Making RS256 opt-in must NOT open algorithm
199
+ # substitution, so both paths share this one pin.
200
+ def decode_envelope(token, algorithm)
201
+ parts = token.to_s.split(".")
202
+ return nil unless parts.length == 3
203
+ return nil unless JSON.parse(base64url_decode(parts[0]))["alg"] == algorithm
204
+ return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
183
205
 
184
- begin
185
- parts = token.split(".")
186
- return nil unless parts.length == 3
206
+ payload = JSON.parse(base64url_decode(parts[1]))
207
+ now = Time.now.to_i
187
208
 
188
- header_json = base64url_decode(parts[0])
189
- header = JSON.parse(header_json)
190
- return nil unless header["alg"] == alg
209
+ # RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
210
+ # current date/time MUST be before the expiration date/time". now == exp
211
+ # is therefore ALREADY expired, so the test is >=.
212
+ #
213
+ # key? — NOT a truthiness test on the value. A PRESENT but malformed exp
214
+ # must never read as "no constraint": `payload["exp"] && ...` skipped the
215
+ # check entirely for exp: null / exp: false, turning a broken token into
216
+ # one that never expires.
217
+ if payload.key?("exp")
218
+ exp = numeric_date(payload["exp"])
219
+ return nil if exp.nil? || now >= exp
220
+ end
191
221
 
192
- # Verify signature
193
- signing_input = "#{parts[0]}.#{parts[1]}"
194
- expected_sig = hmac_signature(alg, secret, signing_input)
195
- actual_sig = base64url_decode(parts[2])
222
+ # "nbf" (not-before): a post-dated token is not valid yet. Tolerate
223
+ # JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second
224
+ # ahead is not rejected for nothing. Same malformed-is-rejected rule as
225
+ # exp; NO nbf key at all stays unconstrained (non-breaking).
226
+ if payload.key?("nbf")
227
+ nbf = numeric_date(payload["nbf"])
228
+ return nil if nbf.nil? || now + JWT_LEEWAY_SECONDS < nbf
229
+ end
196
230
 
231
+ payload
232
+ rescue ArgumentError, JSON::ParserError, OpenSSL::OpenSSLError
233
+ nil
234
+ end
235
+
236
+ # Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the
237
+ # explicit argument, else TINA4_JWT_ALGORITHM, else HS256 — and the header
238
+ # advertises exactly the algorithm that signed.
239
+ def hmac_encode(claims, secret, algorithm: nil)
240
+ alg = resolve_algorithm(algorithm)
241
+ encode_envelope(alg, claims) { |input| hmac_signature(alg, secret, input) }
242
+ end
243
+
244
+ # Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
245
+ def hmac_decode(token, secret, algorithm: nil)
246
+ # Resolved OUTSIDE decode_envelope's rescue: an unsupported algorithm is a
247
+ # configuration error that must surface, not become a nil "invalid token".
248
+ alg = resolve_algorithm(algorithm)
249
+
250
+ decode_envelope(token, alg) do |input, signature|
251
+ expected = hmac_signature(alg, secret, input)
197
252
  # Constant-time comparison to prevent timing attacks. Lengths must match
198
- # first — fixed_length_secure_compare raises on a length mismatch, which
253
+ # first — fixed_length_secure_compare RAISES on a length mismatch, which
199
254
  # a forged signature of the wrong digest size would trigger.
200
- return nil unless expected_sig.bytesize == actual_sig.bytesize
201
- return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
255
+ expected.bytesize == signature.bytesize &&
256
+ OpenSSL.fixed_length_secure_compare(expected, signature)
257
+ end
258
+ end
202
259
 
203
- payload = JSON.parse(base64url_decode(parts[1]))
260
+ # ── RS256: opt-in, stdlib OpenSSL, zero gems ─────────────────
204
261
 
205
- # Check expiry
206
- now = Time.now.to_i
207
- return nil if payload["exp"] && now >= payload["exp"]
262
+ # Does this runtime provide RSA natively? Ruby ships OpenSSL as a stdlib
263
+ # DEFAULT GEM, so this is true on every normal build — there is nothing to
264
+ # install and nothing to opt into at the library level.
265
+ def rs256_available?
266
+ defined?(OpenSSL::PKey::RSA) ? true : false
267
+ end
208
268
 
209
- # "nbf" (not-before): a post-dated token is not valid yet. Tolerate
210
- # JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second
211
- # ahead is not rejected for nothing.
212
- return nil if payload["nbf"] && now + JWT_LEEWAY_SECONDS < payload["nbf"]
269
+ # RSA-SHA256, with a FRESH digest instance per call so nothing about the
270
+ # digest state is shared between calls or threads (same rule as HMAC).
271
+ def rs256_encode(claims)
272
+ require_rs256!
273
+ encode_envelope("RS256", claims) { |input| private_key.sign(OpenSSL::Digest::SHA256.new, input) }
274
+ end
213
275
 
214
- payload
215
- rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
216
- nil
217
- end
276
+ def rs256_decode(token)
277
+ require_rs256!
278
+ decode_envelope(token, "RS256") { |input, sig| public_key.verify(OpenSSL::Digest::SHA256.new, sig, input) }
218
279
  end
219
280
 
220
- # ── Token API (auto-selects HS256 or RS256) ─────────────────
281
+ # ── Token API (HMAC by default; RS256 when RSA keys are present) ──
221
282
 
222
283
  # Mint a signed JWT.
223
284
  #
224
285
  # `algorithm:` selects the HMAC algorithm (else TINA4_JWT_ALGORITHM, else
225
286
  # HS256); an unsupported one raises ArgumentError rather than quietly
226
- # downgrading. It applies to the HMAC path only — the legacy RS256 path
287
+ # downgrading. It applies to the HMAC path only — the opt-in RS256 path
227
288
  # (RSA keys present in .keys/) is unaffected.
228
289
  #
229
290
  # BREAKING (deliberate): no "nbf" claim is stamped. It duplicated "iat",
@@ -244,17 +305,15 @@ module Tina4
244
305
  hmac_encode(claims, hmac_secret, algorithm: algorithm)
245
306
  else
246
307
  ensure_keys
247
- require "jwt"
248
- JWT.encode(claims, private_key, "RS256")
308
+ rs256_encode(claims)
249
309
  end
250
310
  end
251
311
 
252
-
253
312
  # Verify a JWT signature + expiry.
254
313
  #
255
314
  # 3.13.0: return type changed from `Boolean` to `Hash | nil`. The
256
315
  # decoded payload is returned on success, nil on failure. Matches
257
- # firebase/jwt-ruby and Python's Auth.valid_token in 3.13.0.
316
+ # Python's Auth.valid_token in 3.13.0.
258
317
  #
259
318
  # Legacy `if Tina4::Auth.valid_token(t)` patterns keep working
260
319
  # because a non-empty Hash is truthy and nil is falsy.
@@ -263,32 +322,17 @@ module Tina4
263
322
  hmac_decode(token, hmac_secret) # returns Hash payload or nil
264
323
  else
265
324
  ensure_keys
266
- require "jwt"
267
- decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
268
- decoded[0] # firebase/jwt-ruby returns [payload, header]
325
+ rs256_decode(token)
269
326
  end
270
- rescue JWT::ExpiredSignature, JWT::DecodeError
271
- nil
272
327
  end
273
328
 
329
+ # BREAKING (deliberate): the RS256 branch used to surface the jwt gem's own
330
+ # wording ("Signature has expired", or a raw decode message). Both paths now
331
+ # report the single HMAC-path wording, so the detail shape no longer depends
332
+ # on which algorithm signed.
274
333
  def valid_token_detail(token)
275
- if use_hmac?
276
- payload = hmac_decode(token, hmac_secret)
277
- if payload
278
- { valid: true, payload: payload }
279
- else
280
- { valid: false, error: "Invalid or expired token" }
281
- end
282
- else
283
- ensure_keys
284
- require "jwt"
285
- decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
286
- { valid: true, payload: decoded[0] }
287
- end
288
- rescue JWT::ExpiredSignature
289
- { valid: false, error: "Token expired" }
290
- rescue JWT::DecodeError => e
291
- { valid: false, error: e.message }
334
+ payload = valid_token(token)
335
+ payload ? { valid: true, payload: payload } : { valid: false, error: "Invalid or expired token" }
292
336
  end
293
337
 
294
338
  def hash_password(password, salt = nil, iterations = 260000)
@@ -349,23 +393,31 @@ module Tina4
349
393
 
350
394
  token = Regexp.last_match(1)
351
395
 
396
+ # The JWT is checked FIRST, then the API key — the order Python, PHP and
397
+ # Node all use. Ruby checked the API key first, so the same request could
398
+ # authenticate differently depending on the framework.
399
+ #
400
+ # A custom secret and/or algorithm validates against those directly rather
401
+ # than this process's env-resolved defaults.
402
+ payload = if secret || algorithm
403
+ hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
404
+ elsif valid_token(token)
405
+ get_payload(token)
406
+ end
407
+ return payload if payload
408
+
352
409
  # API_KEY bypass — timing-safe comparison via validate_api_key
353
410
  # (OpenSSL.fixed_length_secure_compare). Parity with Python's
354
411
  # authenticate_request (validate_api_key), PHP (hash_equals) and
355
412
  # Node (timingSafeEqual). Never use a plain `==` here — that leaks the
356
413
  # key length/prefix through comparison timing.
357
- if validate_api_key(token)
358
- return { "api_key" => true }
359
- end
360
-
361
- # If a custom secret and/or algorithm is provided, validate against those
362
- # directly rather than this process's env-resolved defaults.
363
- if secret || algorithm
364
- payload = hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
365
- return payload ? payload : nil
366
- end
414
+ #
415
+ # "_auth" is the cross-framework key for a non-JWT auth result; PHP and
416
+ # Node already used it, Python used "auth_type" and Ruby "api_key", so the
417
+ # same successful auth read three different ways.
418
+ return { "_auth" => "api_key" } if validate_api_key(token)
367
419
 
368
- valid_token(token) ? get_payload(token) : nil
420
+ nil
369
421
  end
370
422
 
371
423
  def validate_api_key(provided, expected: nil)
@@ -392,22 +444,24 @@ module Tina4
392
444
 
393
445
  token = Regexp.last_match(1)
394
446
 
447
+ # JWT first, then the API key — same order and same payload shape as
448
+ # authenticate_request above (and as Python/PHP/Node).
449
+ if valid_token(token)
450
+ env["tina4.auth"] = get_payload(token)
451
+ return true
452
+ end
453
+
395
454
  # API_KEY bypass — timing-safe comparison via validate_api_key
396
455
  # (OpenSSL.fixed_length_secure_compare). Parity with Python's
397
456
  # authenticate_request (validate_api_key), PHP (hash_equals) and
398
457
  # Node (timingSafeEqual). Never use a plain `==` here — that leaks the
399
458
  # key length/prefix through comparison timing.
400
459
  if validate_api_key(token)
401
- env["tina4.auth"] = { "api_key" => true }
460
+ env["tina4.auth"] = { "_auth" => "api_key" }
402
461
  return true
403
462
  end
404
463
 
405
- if valid_token(token)
406
- env["tina4.auth"] = get_payload(token)
407
- true
408
- else
409
- false
410
- end
464
+ false
411
465
  end
412
466
  end
413
467
 
@@ -431,6 +485,31 @@ module Tina4
431
485
 
432
486
  private
433
487
 
488
+ # The loud, actionable RS256 gate. NotImplementedError is deliberate: it is
489
+ # Ruby's "not available on this platform" error AND it is NOT a
490
+ # StandardError, so a caller's blanket `rescue => e` cannot swallow it into
491
+ # a mysterious false. Unreachable on any Ruby that can load this file.
492
+ def require_rs256!
493
+ raise NotImplementedError, RS256_UNAVAILABLE_MESSAGE unless rs256_available?
494
+ end
495
+
496
+ # Coerce an RFC 7519 NumericDate claim to integer seconds, else nil.
497
+ #
498
+ # RFC 7519 s2 defines exp/nbf/iat as a NumericDate — a JSON numeric value.
499
+ # A claim that is PRESENT but not a number is malformed, and a malformed
500
+ # constraint must never read as "no constraint": treating a non-numeric exp
501
+ # as absent turns a broken token into one that never expires. Every
502
+ # non-numeric JSON type (null, true/false, String, Array, Hash) returns nil
503
+ # so the caller rejects the token. Mirrors the Python master's
504
+ # _numeric_date; Ruby needs no bool special-case because TrueClass is not
505
+ # an Integer (in Python bool IS an int subclass, so exp: true would compare
506
+ # as 1970).
507
+ def numeric_date(value)
508
+ return nil unless value.is_a?(Integer) || value.is_a?(Float)
509
+
510
+ value.to_i
511
+ end
512
+
434
513
  # ── Dev-secret bootstrap helpers (parity with Python master) ──
435
514
 
436
515
  # Dev when the framework debug flag is truthy (TINA4_DEBUG).
@@ -105,22 +105,30 @@ module Tina4
105
105
  total = model_class.count
106
106
  else
107
107
  where_clause = filter_conditions.join(" AND ")
108
- records = model_class.where(where_clause, filter_values)
109
- total = records.length
110
- # Apply manual pagination for filtered results
111
- records = records.slice(offset, limit) || []
108
+ # Fetch THIS page in SQL (limit + offset) and take the total from a
109
+ # COUNT(*) over the same filter — never rows-returned, never an
110
+ # in-memory re-slice by the absolute offset (ADR-0043 root causes 2
111
+ # and 3, which returned zero rows for a valid page).
112
+ records = model_class.where(where_clause, filter_values,
113
+ limit: limit, offset: offset, order_by: order_by)
114
+ total = model_class.count(where_clause, filter_values)
112
115
  end
113
116
 
114
- res.json({
115
- data: records.map { |r| r.to_h },
116
- total: total,
117
- limit: limit,
118
- offset: offset
119
- })
117
+ # Build the envelope through the ONE canonical derivation — the same
118
+ # DatabaseResult#to_paginate ADR-0043 fixed so the REST list endpoint
119
+ # can never drift from it again. Exactly seven snake_case keys:
120
+ # records, total, page, per_page, total_pages, limit, offset. `records`
121
+ # is this page verbatim, `total` the true COUNT, and page/per_page/
122
+ # total_pages/limit/offset are all derived from the query that ran.
123
+ page_result = Tina4::DatabaseResult.new(
124
+ records.map { |r| r.to_h }, count: total, limit: limit, offset: offset
125
+ )
126
+ res.json(page_result.to_paginate)
120
127
  rescue => e
121
128
  res.json({ error: e.message }, status: 500)
122
129
  end
123
- }, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })
130
+ }, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s],
131
+ model: model_class, model_list: true })
124
132
 
125
133
  # GET /api/{table}/{id} -- get single record
126
134
  Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
@@ -135,7 +143,8 @@ module Tina4
135
143
  rescue => e
136
144
  res.json({ error: e.message }, status: 500)
137
145
  end
138
- }, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
146
+ }, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s],
147
+ model: model_class })
139
148
 
140
149
  # POST /api/{table} -- create record
141
150
  post_route = Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
@@ -153,16 +162,11 @@ module Tina4
153
162
  }, swagger_meta: {
154
163
  summary: "Create #{pretty_name}",
155
164
  tags: [table.to_s],
156
- request_body: {
157
- "description" => "#{pretty_name} data",
158
- "required" => true,
159
- "content" => {
160
- "application/json" => {
161
- "schema" => { "type" => "object" },
162
- "example" => example_body
163
- }
164
- }
165
- }
165
+ # Reference the model's components.schemas entry ($ref, keyed by the
166
+ # model CLASS name) instead of a bare {type:object}; the generated
167
+ # example stays as a sample body. See decisions doc S2.
168
+ model: model_class,
169
+ example: example_body
166
170
  })
167
171
 
168
172
  # Secure-by-default: only opt out of the write gate when public: true.
@@ -194,16 +198,9 @@ module Tina4
194
198
  }, swagger_meta: {
195
199
  summary: "Update #{pretty_name}",
196
200
  tags: [table.to_s],
197
- request_body: {
198
- "description" => "#{pretty_name} data",
199
- "required" => true,
200
- "content" => {
201
- "application/json" => {
202
- "schema" => { "type" => "object" },
203
- "example" => example_body
204
- }
205
- }
206
- }
201
+ # $ref the model schema instead of {type:object} (see decisions S2).
202
+ model: model_class,
203
+ example: example_body
207
204
  })
208
205
 
209
206
  # Secure-by-default: only opt out of the write gate when public: true.
@@ -45,6 +45,25 @@ module Tina4
45
45
  raise NotImplementedError
46
46
  end
47
47
 
48
+ # Evict expired entries and return HOW MANY were evicted.
49
+ #
50
+ # The base answer is 0, and it is an honest one: redis, valkey, memcached
51
+ # and mongodb expire entries SERVER-SIDE, so by the time a sweep runs
52
+ # there is nothing left for this process to reclaim. Backends that hold
53
+ # entries locally - memory, file, database - override this with a real
54
+ # count.
55
+ #
56
+ # It lives on the BASE so sweep is answerable on every provider. It used
57
+ # to exist only on the file backend, so sweep raised NoMethodError on the
58
+ # other six and every caller grew a respond_to?(:sweep) guard - and a
59
+ # guard cannot tell "not supported" from "evicted nothing", which are
60
+ # opposite readings for whoever is watching the number.
61
+ #
62
+ # @return [Integer] number of entries evicted, never negative
63
+ def sweep
64
+ 0
65
+ end
66
+
48
67
  # Whether this backend is actually usable (driver present + service
49
68
  # reachable). Local backends are always available; network/driver backends
50
69
  # override this so the factory can fall back to the file backend.
@@ -102,6 +102,35 @@ module Tina4
102
102
  "database"
103
103
  end
104
104
 
105
+ # Evict expired rows and return how many were deleted.
106
+ #
107
+ # A SQL table expires NOTHING by itself. Unlike redis/valkey/memcached/
108
+ # mongodb, which reclaim server-side, an expired row here sits in
109
+ # tina4_cache until something removes it - and the only thing that did was
110
+ # get(), which deletes a single row when someone happens to re-read that
111
+ # exact key. A key never read again was never reclaimed, so the table grew
112
+ # without bound while sweep, the one API whose job is reclaiming it,
113
+ # returned 0 and did nothing.
114
+ #
115
+ # expires_at > 0 is the guard that matters: this backend stores 0 for "no
116
+ # expiry" (get() uses the same guard), so a sweep comparing only
117
+ # expires_at < now would delete every permanent entry on its first run.
118
+ #
119
+ # COUNT then DELETE, against one captured timestamp, so the number
120
+ # returned is exactly the number of rows the DELETE removes on every
121
+ # engine - affected-row counts are best-effort outside SQLite.
122
+ def sweep
123
+ now = Time.now.to_f
124
+ row = @db.fetch_one(
125
+ "SELECT COUNT(*) AS c FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now]
126
+ )
127
+ expired = row ? (row["c"] || row[:c]).to_i : 0
128
+ return 0 if expired.zero?
129
+
130
+ @db.execute("DELETE FROM tina4_cache WHERE expires_at > 0 AND expires_at < ?", [now])
131
+ expired
132
+ end
133
+
105
134
  private
106
135
 
107
136
  def env_nonempty(key)