tina4ruby 3.0.0 → 3.9.2

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -32
  3. data/lib/tina4/auth.rb +137 -27
  4. data/lib/tina4/auto_crud.rb +55 -3
  5. data/lib/tina4/cli.rb +228 -28
  6. data/lib/tina4/cors.rb +1 -1
  7. data/lib/tina4/database.rb +230 -26
  8. data/lib/tina4/database_result.rb +122 -8
  9. data/lib/tina4/dev_mailbox.rb +1 -1
  10. data/lib/tina4/env.rb +1 -1
  11. data/lib/tina4/frond.rb +314 -7
  12. data/lib/tina4/gallery/queue/meta.json +1 -1
  13. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
  14. data/lib/tina4/localization.rb +1 -1
  15. data/lib/tina4/messenger.rb +111 -33
  16. data/lib/tina4/middleware.rb +349 -1
  17. data/lib/tina4/migration.rb +132 -11
  18. data/lib/tina4/orm.rb +149 -18
  19. data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
  20. data/lib/tina4/public/js/tina4js.min.js +47 -0
  21. data/lib/tina4/query_builder.rb +374 -0
  22. data/lib/tina4/queue.rb +219 -61
  23. data/lib/tina4/queue_backends/lite_backend.rb +42 -7
  24. data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
  25. data/lib/tina4/rack_app.rb +200 -11
  26. data/lib/tina4/request.rb +14 -1
  27. data/lib/tina4/response.rb +26 -0
  28. data/lib/tina4/response_cache.rb +446 -29
  29. data/lib/tina4/router.rb +127 -0
  30. data/lib/tina4/service_runner.rb +1 -1
  31. data/lib/tina4/session.rb +6 -1
  32. data/lib/tina4/session_handlers/database_handler.rb +66 -0
  33. data/lib/tina4/swagger.rb +1 -1
  34. data/lib/tina4/templates/errors/404.twig +2 -2
  35. data/lib/tina4/templates/errors/500.twig +1 -1
  36. data/lib/tina4/validator.rb +174 -0
  37. data/lib/tina4/version.rb +1 -1
  38. data/lib/tina4/websocket.rb +23 -4
  39. data/lib/tina4/websocket_backplane.rb +118 -0
  40. data/lib/tina4.rb +126 -5
  41. metadata +40 -3
data/lib/tina4/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "fileutils"
5
5
 
6
6
  module Tina4
7
7
  class CLI
8
- COMMANDS = %w[init start migrate seed seed:create test version routes console ai help].freeze
8
+ COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
9
9
 
10
10
  def self.start(argv)
11
11
  new.run(argv)
@@ -17,12 +17,15 @@ module Tina4
17
17
  when "init" then cmd_init(argv)
18
18
  when "start" then cmd_start(argv)
19
19
  when "migrate" then cmd_migrate(argv)
20
+ when "migrate:status" then cmd_migrate_status(argv)
21
+ when "migrate:rollback" then cmd_migrate_rollback(argv)
20
22
  when "seed" then cmd_seed(argv)
21
23
  when "seed:create" then cmd_seed_create(argv)
22
24
  when "test" then cmd_test(argv)
23
25
  when "version" then cmd_version
24
26
  when "routes" then cmd_routes
25
27
  when "console" then cmd_console
28
+ when "generate" then cmd_generate(argv)
26
29
  when "ai" then cmd_ai(argv)
27
30
  when "help", "-h", "--help" then cmd_help
28
31
  else
@@ -97,37 +100,45 @@ module Tina4
97
100
 
98
101
  app = Tina4::RackApp.new(root_dir: root_dir)
99
102
 
103
+ is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
104
+
100
105
  # Try Puma first (production-grade), fall back to WEBrick
101
- begin
102
- require "puma"
103
- require "puma/configuration"
104
- require "puma/launcher"
105
-
106
- puma_host = options[:host]
107
- puma_port = options[:port]
108
-
109
- config = Puma::Configuration.new do |user_config|
110
- user_config.bind "tcp://#{puma_host}:#{puma_port}"
111
- user_config.app app
112
- user_config.threads 0, 16
113
- user_config.workers 0
114
- user_config.environment "development"
115
- user_config.log_requests false
116
- user_config.quiet
117
- end
106
+ # In debug mode, always use WEBrick for dev toolbar/reload support
107
+ if !is_debug
108
+ begin
109
+ require "puma"
110
+ require "puma/configuration"
111
+ require "puma/launcher"
112
+
113
+ puma_host = options[:host]
114
+ puma_port = options[:port]
115
+
116
+ config = Puma::Configuration.new do |user_config|
117
+ user_config.bind "tcp://#{puma_host}:#{puma_port}"
118
+ user_config.app app
119
+ user_config.threads 0, 16
120
+ user_config.workers 0
121
+ user_config.environment "production"
122
+ user_config.log_requests false
123
+ user_config.quiet
124
+ end
118
125
 
119
- Tina4::Log.info("Starting Puma server on http://#{puma_host}:#{puma_port}")
126
+ Tina4::Log.info("Production server: puma")
120
127
 
121
- # Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
122
- Tina4::Shutdown.setup
128
+ # Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
129
+ Tina4::Shutdown.setup
123
130
 
124
- launcher = Puma::Launcher.new(config)
125
- launcher.run
126
- rescue LoadError
127
- Tina4::Log.info("Puma not found, falling back to WEBrick")
128
- server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
129
- server.start
131
+ launcher = Puma::Launcher.new(config)
132
+ launcher.run
133
+ return
134
+ rescue LoadError
135
+ # Puma not installed, fall through to WEBrick
136
+ end
130
137
  end
138
+
139
+ Tina4::Log.info("Development server: WEBrick")
140
+ server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
141
+ server.start
131
142
  end
132
143
 
133
144
  # ── migrate ───────────────────────────────────────────────────────────
@@ -171,6 +182,75 @@ module Tina4
171
182
  end
172
183
  end
173
184
 
185
+ # ── migrate:status ─────────────────────────────────────────────────────
186
+
187
+ def cmd_migrate_status(_argv)
188
+ require_relative "../tina4"
189
+ Tina4.initialize!(Dir.pwd)
190
+
191
+ db = Tina4.database
192
+ unless db
193
+ puts "No database configured. Set DATABASE_URL in your .env file."
194
+ return
195
+ end
196
+
197
+ migration = Tina4::Migration.new(db)
198
+ info = migration.status
199
+
200
+ puts "\nMigration Status"
201
+ puts "-" * 60
202
+
203
+ if info[:completed].any?
204
+ puts "\nCompleted:"
205
+ info[:completed].each { |name| puts " [OK] #{name}" }
206
+ end
207
+
208
+ if info[:pending].any?
209
+ puts "\nPending:"
210
+ info[:pending].each { |name| puts " [ ] #{name}" }
211
+ end
212
+
213
+ if info[:completed].empty? && info[:pending].empty?
214
+ puts " No migrations found."
215
+ end
216
+
217
+ puts "-" * 60
218
+ puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
219
+ end
220
+
221
+ # ── migrate:rollback ───────────────────────────────────────────────────
222
+
223
+ def cmd_migrate_rollback(argv)
224
+ options = { steps: 1 }
225
+ parser = OptionParser.new do |opts|
226
+ opts.banner = "Usage: tina4ruby migrate:rollback [options]"
227
+ opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
228
+ end
229
+ parser.parse!(argv)
230
+
231
+ require_relative "../tina4"
232
+ Tina4.initialize!(Dir.pwd)
233
+
234
+ db = Tina4.database
235
+ unless db
236
+ puts "No database configured. Set DATABASE_URL in your .env file."
237
+ return
238
+ end
239
+
240
+ migration = Tina4::Migration.new(db)
241
+ results = migration.rollback(options[:steps])
242
+
243
+ if results.empty?
244
+ puts "Nothing to rollback."
245
+ else
246
+ results.each do |r|
247
+ status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
248
+ puts " [#{status_icon}] #{r[:name]}"
249
+ end
250
+ puts "Rolled back #{results.length} migration(s)."
251
+ end
252
+ end
253
+
174
254
  # ── seed ──────────────────────────────────────────────────────────────
175
255
 
176
256
  def cmd_seed(argv)
@@ -310,6 +390,123 @@ module Tina4
310
390
 
311
391
  # ── help ──────────────────────────────────────────────────────────────
312
392
 
393
+ # ── generate ────────────────────────────────────────────────────────
394
+
395
+ def cmd_generate(argv)
396
+ what = argv.shift
397
+ name = argv.shift
398
+ unless what && name
399
+ puts "Usage: tina4ruby generate <what> <name>"
400
+ puts " Generators: model, route, migration, middleware"
401
+ exit 1
402
+ end
403
+
404
+ case what
405
+ when "model" then generate_model(name)
406
+ when "route" then generate_route(name)
407
+ when "migration" then generate_migration(name)
408
+ when "middleware" then generate_middleware(name)
409
+ else
410
+ puts "Unknown generator: #{what}"
411
+ puts " Available: model, route, migration, middleware"
412
+ exit 1
413
+ end
414
+ end
415
+
416
+ def generate_model(name)
417
+ dir = "src/orm"
418
+ FileUtils.mkdir_p(dir)
419
+ snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
420
+ path = File.join(dir, "#{snake}.rb")
421
+ abort " File already exists: #{path}" if File.exist?(path)
422
+ File.write(path, <<~RUBY)
423
+ class #{name} < Tina4::ORM
424
+ integer_field :id, primary_key: true, auto_increment: true
425
+ string_field :name
426
+ string_field :email
427
+ end
428
+ RUBY
429
+ puts " Created #{path}"
430
+ end
431
+
432
+ def generate_route(name)
433
+ route_path = name.sub(%r{^/}, "")
434
+ dir = "src/routes/#{route_path}"
435
+ FileUtils.mkdir_p(dir)
436
+ path = dir.chomp("/") + ".rb"
437
+ abort " File already exists: #{path}" if File.exist?(path)
438
+ File.write(path, <<~RUBY)
439
+ Tina4.get "/#{route_path}" do |request, response|
440
+ response.json(data: [])
441
+ end
442
+
443
+ Tina4.get "/#{route_path}/:id" do |request, response|
444
+ response.json(data: {})
445
+ end
446
+
447
+ Tina4.post "/#{route_path}" do |request, response|
448
+ response.json({ message: "created" }, 201)
449
+ end
450
+
451
+ Tina4.put "/#{route_path}/:id" do |request, response|
452
+ response.json(message: "updated")
453
+ end
454
+
455
+ Tina4.delete "/#{route_path}/:id" do |request, response|
456
+ response.json(message: "deleted")
457
+ end
458
+ RUBY
459
+ puts " Created #{path}"
460
+ end
461
+
462
+ def generate_migration(name)
463
+ dir = "migrations"
464
+ FileUtils.mkdir_p(dir)
465
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
466
+ table = name.sub(/^create_/, "")
467
+ table = if table.end_with?("s")
468
+ table
469
+ elsif table.end_with?("y")
470
+ table[0..-2] + "ies"
471
+ else
472
+ table + "s"
473
+ end
474
+ filename = "#{timestamp}_#{name}.sql"
475
+ path = File.join(dir, filename)
476
+ now = Time.now.strftime("%Y-%m-%d %H:%M:%S")
477
+ File.write(path, <<~SQL)
478
+ -- Migration: #{name}
479
+ -- Created: #{now}
480
+
481
+ CREATE TABLE #{table} (
482
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
483
+ name TEXT NOT NULL,
484
+ email TEXT NOT NULL,
485
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
486
+ );
487
+ SQL
488
+ puts " Created #{path}"
489
+ end
490
+
491
+ def generate_middleware(name)
492
+ dir = "src/middleware"
493
+ FileUtils.mkdir_p(dir)
494
+ snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
495
+ path = File.join(dir, "#{snake}.rb")
496
+ abort " File already exists: #{path}" if File.exist?(path)
497
+ File.write(path, <<~RUBY)
498
+ class #{name} < Tina4::Middleware
499
+ def process(request, response)
500
+ auth = request.headers["Authorization"]
501
+ return response.json({ error: "Unauthorized" }, 401) unless auth
502
+
503
+ nil
504
+ end
505
+ end
506
+ RUBY
507
+ puts " Created #{path}"
508
+ end
509
+
313
510
  def cmd_help
314
511
  puts <<~HELP
315
512
  Tina4 Ruby CLI
@@ -320,12 +517,15 @@ module Tina4
320
517
  init [NAME] Initialize a new Tina4 project
321
518
  start Start the Tina4 web server
322
519
  migrate Run database migrations
520
+ migrate:status Show migration status (completed and pending)
521
+ migrate:rollback Rollback the last batch of migrations
323
522
  seed Run all seed files in seeds/
324
523
  seed:create NAME Create a new seed file
325
524
  test Run inline tests
326
525
  version Show Tina4 version
327
526
  routes List all registered routes
328
527
  console Start an interactive console
528
+ generate <what> <name> Generate scaffolding (model, route, migration, middleware)
329
529
  ai Detect AI tools and install context files
330
530
  help Show this help message
331
531
 
@@ -355,7 +555,7 @@ module Tina4
355
555
  # ── shared helpers ────────────────────────────────────────────────────
356
556
 
357
557
  def load_routes(root_dir)
358
- route_dirs = %w[routes src/routes src/api api]
558
+ route_dirs = %w[src/routes routes src/api api src/orm orm]
359
559
  route_dirs.each do |dir|
360
560
  route_dir = File.join(root_dir, dir)
361
561
  next unless Dir.exist?(route_dir)
data/lib/tina4/cors.rb CHANGED
@@ -52,7 +52,7 @@ module Tina4
52
52
  {
53
53
  origins: ENV["TINA4_CORS_ORIGINS"] || "*",
54
54
  methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
55
- headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type, Authorization, Accept",
55
+ headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
56
56
  max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
57
57
  credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
58
58
  }.freeze
@@ -1,10 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
3
  require "uri"
4
+ require "digest"
4
5
 
5
6
  module Tina4
7
+ # Thread-safe connection pool with round-robin rotation.
8
+ # Connections are created lazily on first use.
9
+ class ConnectionPool
10
+ attr_reader :size
11
+
12
+ def initialize(pool_size, driver_factory:, connection_string:, username: nil, password: nil)
13
+ @pool_size = pool_size
14
+ @driver_factory = driver_factory
15
+ @connection_string = connection_string
16
+ @username = username
17
+ @password = password
18
+ @drivers = Array.new(pool_size) # nil slots — lazy creation
19
+ @index = 0
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Get the next driver via round-robin. Thread-safe.
24
+ def checkout
25
+ @mutex.synchronize do
26
+ idx = @index
27
+ @index = (@index + 1) % @pool_size
28
+
29
+ if @drivers[idx].nil?
30
+ driver = @driver_factory.call
31
+ driver.connect(@connection_string, username: @username, password: @password)
32
+ @drivers[idx] = driver
33
+ end
34
+
35
+ @drivers[idx]
36
+ end
37
+ end
38
+
39
+ # Return a driver to the pool. Currently a no-op for round-robin.
40
+ def checkin(_driver)
41
+ # no-op
42
+ end
43
+
44
+ # Close all active connections.
45
+ def close_all
46
+ @mutex.synchronize do
47
+ @drivers.each_with_index do |driver, i|
48
+ if driver
49
+ driver.close rescue nil
50
+ @drivers[i] = nil
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # Number of connections that have been created.
57
+ def active_count
58
+ @mutex.synchronize do
59
+ @drivers.count { |d| !d.nil? }
60
+ end
61
+ end
62
+
63
+ def size
64
+ @pool_size
65
+ end
66
+ end
67
+
6
68
  class Database
7
- attr_reader :driver, :driver_name, :connected
69
+ attr_reader :driver, :driver_name, :connected, :pool
8
70
 
9
71
  DRIVERS = {
10
72
  "sqlite" => "Tina4::Drivers::SqliteDriver",
@@ -17,19 +79,50 @@ module Tina4
17
79
  "firebird" => "Tina4::Drivers::FirebirdDriver"
18
80
  }.freeze
19
81
 
20
- def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil)
82
+ def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
21
83
  @connection_string = connection_string || ENV["DATABASE_URL"]
22
84
  @username = username || ENV["DATABASE_USERNAME"]
23
85
  @password = password || ENV["DATABASE_PASSWORD"]
24
86
  @driver_name = driver_name || detect_driver(@connection_string)
25
- @driver = create_driver
87
+ @pool_size = pool # 0 = single connection, N>0 = N pooled connections
26
88
  @connected = false
27
- connect
89
+
90
+ # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
91
+ @cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
92
+ @cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
93
+ @query_cache = {} # key => { expires_at:, value: }
94
+ @cache_hits = 0
95
+ @cache_misses = 0
96
+ @cache_mutex = Mutex.new
97
+
98
+ if @pool_size > 0
99
+ # Pooled mode — create a ConnectionPool with lazy driver creation
100
+ @pool = ConnectionPool.new(
101
+ @pool_size,
102
+ driver_factory: method(:create_driver),
103
+ connection_string: @connection_string,
104
+ username: @username,
105
+ password: @password
106
+ )
107
+ @driver = nil
108
+ @connected = true
109
+ else
110
+ # Single-connection mode — current behavior
111
+ @pool = nil
112
+ @driver = create_driver
113
+ connect
114
+ end
28
115
  end
29
116
 
30
117
  def connect
31
118
  @driver.connect(@connection_string, username: @username, password: @password)
32
119
  @connected = true
120
+
121
+ # Enable autocommit if TINA4_AUTOCOMMIT env var is set
122
+ if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
123
+ @driver.autocommit = true
124
+ end
125
+
33
126
  Tina4::Log.info("Database connected: #{@driver_name}")
34
127
  rescue => e
35
128
  Tina4::Log.error("Database connection failed: #{e.message}")
@@ -37,53 +130,129 @@ module Tina4
37
130
  end
38
131
 
39
132
  def close
40
- @driver.close if @connected
133
+ if @pool
134
+ @pool.close_all
135
+ elsif @driver && @connected
136
+ @driver.close
137
+ end
41
138
  @connected = false
42
139
  end
43
140
 
44
- def fetch(sql, params = [], limit: nil, skip: nil)
141
+ # Get the current driver from pool (round-robin) or single connection.
142
+ def current_driver
143
+ if @pool
144
+ @pool.checkout
145
+ else
146
+ @driver
147
+ end
148
+ end
149
+
150
+ # ── Query Cache ──────────────────────────────────────────────
151
+
152
+ def cache_stats
153
+ @cache_mutex.synchronize do
154
+ {
155
+ enabled: @cache_enabled,
156
+ hits: @cache_hits,
157
+ misses: @cache_misses,
158
+ size: @query_cache.size,
159
+ ttl: @cache_ttl
160
+ }
161
+ end
162
+ end
163
+
164
+ def cache_clear
165
+ @cache_mutex.synchronize do
166
+ @query_cache.clear
167
+ @cache_hits = 0
168
+ @cache_misses = 0
169
+ end
170
+ end
171
+
172
+ def fetch(sql, params = [], limit: nil, offset: nil)
173
+ offset ||= 0
174
+ drv = current_driver
175
+
45
176
  effective_sql = sql
46
177
  if limit
47
- effective_sql = @driver.apply_limit(effective_sql, limit, skip || 0)
178
+ effective_sql = drv.apply_limit(effective_sql, limit, offset)
179
+ end
180
+
181
+ if @cache_enabled
182
+ key = cache_key(effective_sql, params)
183
+ cached = cache_get(key)
184
+ if cached
185
+ @cache_mutex.synchronize { @cache_hits += 1 }
186
+ return cached
187
+ end
188
+ result = drv.execute_query(effective_sql, params)
189
+ result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
190
+ cache_set(key, result)
191
+ @cache_mutex.synchronize { @cache_misses += 1 }
192
+ return result
48
193
  end
49
- rows = @driver.execute_query(effective_sql, params)
50
- Tina4::DatabaseResult.new(rows, sql: effective_sql)
194
+
195
+ rows = drv.execute_query(effective_sql, params)
196
+ Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
51
197
  end
52
198
 
53
199
  def fetch_one(sql, params = [])
200
+ if @cache_enabled
201
+ key = cache_key(sql + ":ONE", params)
202
+ cached = cache_get(key)
203
+ if cached
204
+ @cache_mutex.synchronize { @cache_hits += 1 }
205
+ return cached
206
+ end
207
+ result = fetch(sql, params, limit: 1)
208
+ value = result.first
209
+ cache_set(key, value)
210
+ @cache_mutex.synchronize { @cache_misses += 1 }
211
+ return value
212
+ end
213
+
54
214
  result = fetch(sql, params, limit: 1)
55
215
  result.first
56
216
  end
57
217
 
58
218
  def insert(table, data)
219
+ cache_invalidate if @cache_enabled
220
+ drv = current_driver
221
+
59
222
  # List of hashes — batch insert
60
223
  if data.is_a?(Array)
61
224
  return { success: true, affected_rows: 0 } if data.empty?
62
225
  keys = data.first.keys.map(&:to_s)
63
- placeholders = @driver.placeholders(keys.length)
226
+ placeholders = drv.placeholders(keys.length)
64
227
  sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
65
228
  params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
66
229
  return execute_many(sql, params_list)
67
230
  end
68
231
 
69
232
  columns = data.keys.map(&:to_s)
70
- placeholders = @driver.placeholders(columns.length)
233
+ placeholders = drv.placeholders(columns.length)
71
234
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
72
- @driver.execute(sql, data.values)
73
- { success: true, last_id: @driver.last_insert_id }
235
+ drv.execute(sql, data.values)
236
+ { success: true, last_id: drv.last_insert_id }
74
237
  end
75
238
 
76
239
  def update(table, data, filter = {})
77
- set_parts = data.keys.map { |k| "#{k} = #{@driver.placeholder}" }
78
- where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
240
+ cache_invalidate if @cache_enabled
241
+ drv = current_driver
242
+
243
+ set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
244
+ where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
79
245
  sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
80
246
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
81
247
  values = data.values + filter.values
82
- @driver.execute(sql, values)
248
+ drv.execute(sql, values)
83
249
  { success: true }
84
250
  end
85
251
 
86
252
  def delete(table, filter = {})
253
+ cache_invalidate if @cache_enabled
254
+ drv = current_driver
255
+
87
256
  # List of hashes — delete each row
88
257
  if filter.is_a?(Array)
89
258
  filter.each { |row| delete(table, row) }
@@ -94,46 +263,48 @@ module Tina4
94
263
  if filter.is_a?(String)
95
264
  sql = "DELETE FROM #{table}"
96
265
  sql += " WHERE #{filter}" unless filter.empty?
97
- @driver.execute(sql)
266
+ drv.execute(sql)
98
267
  return { success: true }
99
268
  end
100
269
 
101
270
  # Hash filter — build WHERE from keys
102
- where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
271
+ where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
103
272
  sql = "DELETE FROM #{table}"
104
273
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
105
- @driver.execute(sql, filter.values)
274
+ drv.execute(sql, filter.values)
106
275
  { success: true }
107
276
  end
108
277
 
109
278
  def execute(sql, params = [])
110
- @driver.execute(sql, params)
279
+ cache_invalidate if @cache_enabled
280
+ current_driver.execute(sql, params)
111
281
  end
112
282
 
113
283
  def execute_many(sql, params_list = [])
114
284
  total_affected = 0
115
285
  params_list.each do |params|
116
- @driver.execute(sql, params)
286
+ current_driver.execute(sql, params)
117
287
  total_affected += 1
118
288
  end
119
289
  { success: true, affected_rows: total_affected }
120
290
  end
121
291
 
122
292
  def transaction
123
- @driver.begin_transaction
293
+ drv = current_driver
294
+ drv.begin_transaction
124
295
  yield self
125
- @driver.commit
296
+ drv.commit
126
297
  rescue => e
127
- @driver.rollback
298
+ drv.rollback
128
299
  raise e
129
300
  end
130
301
 
131
302
  def tables
132
- @driver.tables
303
+ current_driver.tables
133
304
  end
134
305
 
135
306
  def columns(table_name)
136
- @driver.columns(table_name)
307
+ current_driver.columns(table_name)
137
308
  end
138
309
 
139
310
  def table_exists?(table_name)
@@ -142,6 +313,39 @@ module Tina4
142
313
 
143
314
  private
144
315
 
316
+ def truthy?(val)
317
+ %w[true 1 yes on].include?((val || "").to_s.strip.downcase)
318
+ end
319
+
320
+ def cache_key(sql, params)
321
+ Digest::SHA256.hexdigest(sql + params.to_s)
322
+ end
323
+
324
+ def cache_get(key)
325
+ @cache_mutex.synchronize do
326
+ entry = @query_cache[key]
327
+ return nil unless entry
328
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
329
+ @query_cache.delete(key)
330
+ return nil
331
+ end
332
+ entry[:value]
333
+ end
334
+ end
335
+
336
+ def cache_set(key, value)
337
+ @cache_mutex.synchronize do
338
+ @query_cache[key] = {
339
+ expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
340
+ value: value
341
+ }
342
+ end
343
+ end
344
+
345
+ def cache_invalidate
346
+ @cache_mutex.synchronize { @query_cache.clear }
347
+ end
348
+
145
349
  def detect_driver(conn)
146
350
  case conn.to_s.downcase
147
351
  when /\.db$/, /\.sqlite/, /sqlite/