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
@@ -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).
@@ -16,7 +16,10 @@ module Tina4
16
16
  end
17
17
 
18
18
  # Capture an outgoing email to the local filesystem instead of sending
19
- def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
19
+ # text: carries the plain-text alternative onto the dev path. Every framework
20
+ # used to drop it, so what you inspected in the mailbox was not what would have
21
+ # been sent -- a mailbox that shows you a different message is worse than none.
22
+ def capture(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
20
23
  reply_to: nil, from_address: nil, from_name: nil, attachments: [])
21
24
  msg_id = SecureRandom.uuid
22
25
  timestamp = Time.now
@@ -30,6 +33,7 @@ module Tina4
30
33
  reply_to: reply_to,
31
34
  subject: subject,
32
35
  body: body,
36
+ text: text,
33
37
  html: html,
34
38
  attachments: store_attachments(msg_id, attachments),
35
39
  read: false,