tina4ruby 3.13.37 → 3.13.39

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.
@@ -199,19 +199,32 @@ module Tina4
199
199
  # commit/rollback clear it. While pinned, current_driver returns the same
200
200
  # driver for every call so the whole transaction runs on one connection.
201
201
  @tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
202
-
203
- # Query cache. One store, two layers (parity with Python connection.py):
204
- # request-scoped (DEFAULT ON, off-switch TINA4_AUTO_CACHING=false)
205
- # dedupes identical SELECTs to protect the DB from rapid repeat reads.
206
- # Cleared at the START of every HTTP request (so it never serves rows
207
- # across requests) AND on any write, with a short safety TTL (5s) for
208
- # non-request contexts (scripts/workers).
202
+ # Per-thread nested-transaction depth counter (DB-contract C, v3.13.37).
203
+ # A second start_transaction on a thread that already holds the pin is a
204
+ # double-begin: most engines silently commit or no-op the inner BEGIN,
205
+ # leaving the connection mid-transaction. We warn + increment depth instead
206
+ # of re-beginning; the inner commit just decrements; the outer commit/any
207
+ # rollback releases the pin.
208
+ @tx_depth_key = :"tina4_tx_depth_#{object_id}"
209
+
210
+ # Query cache. One store, two layers (parity with Python connection.py).
211
+ # BOTH layers are OPT-IN — the DEFAULT is OFF.
212
+ #
213
+ # A request-scoped cache that defaults ON is a footgun: a SELECT MAX(id)
214
+ # (or generator read) right before an INSERT in the SAME request returns a
215
+ # cached pre-write value → duplicate primary keys, and any read-after-write
216
+ # in one request shows stale state. So both layers default OFF:
217
+ # • request-scoped (opt-in, TINA4_AUTO_CACHING=true) — dedupes identical
218
+ # SELECTs to protect the DB from rapid repeat reads on read-heavy
219
+ # endpoints. Cleared at the START of every HTTP request (so it never
220
+ # serves rows across requests) AND on any write, with a short safety
221
+ # TTL (5s) for non-request contexts (scripts/workers).
209
222
  # • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache
210
223
  # that is NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL.
211
224
  @cache_persistent = truthy?(ENV["TINA4_DB_CACHE"])
212
- # Default true; honour the same truthy semantics the framework uses
213
- # (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "true"))).
214
- @cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "true")
225
+ # Default OFF; honour the same truthy semantics the framework uses
226
+ # (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "false"))).
227
+ @cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "false")
215
228
  @cache_enabled = @cache_persistent || @cache_request_scoped
216
229
  @cache_ttl = if @cache_persistent
217
230
  (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
@@ -242,6 +255,17 @@ module Tina4
242
255
  end
243
256
  end
244
257
 
258
+ # Autocommit is ON by default — parity with Python/PHP/Node. A standalone
259
+ # write (execute/insert/update/delete made OUTSIDE an explicit
260
+ # start_transaction()/commit() block) commits on its own connection before
261
+ # returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
262
+ # treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
263
+ # write needs an explicit commit). Inside an explicit transaction the
264
+ # framework-issued commit is suppressed (gated on the thread tx-pin), so
265
+ # explicit transactions stay atomic. Mirrors Python's
266
+ # DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
267
+ @autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))
268
+
245
269
  # Register this connection so Tina4::Database.reset_request_caches can
246
270
  # clear its request-scoped entries at the start of every HTTP request.
247
271
  Tina4::Database.register_instance(self)
@@ -269,10 +293,11 @@ module Tina4
269
293
  @driver.connect(@connection_string, username: @username, password: @password)
270
294
  @connected = true
271
295
 
272
- # Enable autocommit if TINA4_AUTOCOMMIT env var is set
273
- if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
274
- @driver.autocommit = true
275
- end
296
+ # Push the resolved autocommit setting down to the driver when it exposes a
297
+ # native toggle (default ON — see @autocommit in #initialize). The
298
+ # framework-level commit in #autocommit_standalone_write covers drivers
299
+ # that have no native setter.
300
+ @driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
276
301
 
277
302
  Tina4::Log.info("Database connected: #{@driver_name}")
278
303
  rescue => e
@@ -374,6 +399,14 @@ module Tina4
374
399
 
375
400
  # Fetch rows with pagination, returning a DatabaseResult.
376
401
  #
402
+ # FAILS LOUD (v3.13.37, DB-contract A): a SQL error in the main query
403
+ # propagates — a typo'd / bad SELECT RAISES, it never silently returns an
404
+ # empty result. The cause is captured on @last_error / #get_error before the
405
+ # re-raise (parity with #execute and the Python master), so the public API
406
+ # can read why it failed even for engines whose driver doesn't expose its
407
+ # own last_error. Because the raise happens BEFORE cache_set is reached, a
408
+ # buried failure is never written into the query cache.
409
+ #
377
410
  # Pass `no_cache: true` to bypass the query cache entirely for this single
378
411
  # call — no lookup, no store — and run the query directly against the
379
412
  # driver. Works for both the request-scoped auto-cache and the persistent
@@ -404,22 +437,30 @@ module Tina4
404
437
  @cache_mutex.synchronize { @cache_hits += 1 }
405
438
  return cached
406
439
  end
407
- result = drv.execute_query(effective_sql, params)
408
- result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
440
+ # fetch_direct RAISES on a SQL error (and captures @last_error), so a
441
+ # failed read never reaches cache_set below — we never cache an empty
442
+ # result produced by a buried failure.
443
+ result = fetch_direct(drv, effective_sql, params)
409
444
  cache_set(key, result)
410
445
  @cache_mutex.synchronize { @cache_misses += 1 }
411
446
  return result
412
447
  end
413
448
 
414
- rows = drv.execute_query(effective_sql, params)
415
- Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
449
+ fetch_direct(drv, effective_sql, params)
416
450
  end
417
451
 
418
452
  # Fetch a single row (or nil).
419
453
  #
454
+ # FAILS LOUD (v3.13.37, DB-contract A): a SQL error RAISES and populates
455
+ # @last_error / #get_error the same way #execute and #fetch do — pre-fix
456
+ # fetch_one ran the query through #fetch but did not separately guarantee the
457
+ # error capture, and a buried failure could be cached as nil. It now routes
458
+ # the uncached path through fetch_one_direct (capture + re-raise) and, on the
459
+ # cached path, only ever stores a value produced by a SUCCESSFUL read.
460
+ #
420
461
  # Pass `no_cache: true` to bypass the query cache entirely for this call —
421
462
  # no lookup, no store — running the query directly. The `no_cache` flag is
422
- # propagated to the inner #fetch so the request-scoped/persistent cache is
463
+ # propagated to the inner read so the request-scoped/persistent cache is
423
464
  # never populated either. Default `false` preserves cached behaviour.
424
465
  def fetch_one(sql, params = [], no_cache: false)
425
466
  sql = Tina4::Database.strip_trailing_semicolons(sql)
@@ -430,15 +471,15 @@ module Tina4
430
471
  @cache_mutex.synchronize { @cache_hits += 1 }
431
472
  return cached
432
473
  end
433
- result = fetch(sql, params, limit: 1)
434
- value = result.first
474
+ # Raises (and captures @last_error) BEFORE cache_set, so a failed read
475
+ # is never cached as nil.
476
+ value = fetch_one_direct(sql, params)
435
477
  cache_set(key, value)
436
478
  @cache_mutex.synchronize { @cache_misses += 1 }
437
479
  return value
438
480
  end
439
481
 
440
- result = fetch(sql, params, limit: 1, no_cache: no_cache)
441
- result.first
482
+ fetch_one_direct(sql, params)
442
483
  end
443
484
 
444
485
  def insert(table, data)
@@ -459,7 +500,9 @@ module Tina4
459
500
  placeholders = drv.placeholders(columns.length)
460
501
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
461
502
  drv.execute(sql, data.values)
462
- { success: true, last_id: drv.last_insert_id }
503
+ last_id = drv.last_insert_id
504
+ autocommit_standalone_write(drv)
505
+ { success: true, last_id: last_id }
463
506
  end
464
507
 
465
508
  def update(table, data, filter = {}, params = nil)
@@ -472,6 +515,7 @@ module Tina4
472
515
  sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
473
516
  sql += " WHERE #{filter}" unless filter.empty?
474
517
  drv.execute(sql, data.values + Array(params))
518
+ autocommit_standalone_write(drv)
475
519
  return { success: true }
476
520
  end
477
521
 
@@ -481,6 +525,7 @@ module Tina4
481
525
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
482
526
  values = data.values + filter.values
483
527
  drv.execute(sql, values)
528
+ autocommit_standalone_write(drv)
484
529
  { success: true }
485
530
  end
486
531
 
@@ -499,6 +544,7 @@ module Tina4
499
544
  sql = "DELETE FROM #{table}"
500
545
  sql += " WHERE #{filter}" unless filter.empty?
501
546
  drv.execute(sql, Array(params))
547
+ autocommit_standalone_write(drv)
502
548
  return { success: true }
503
549
  end
504
550
 
@@ -507,6 +553,7 @@ module Tina4
507
553
  sql = "DELETE FROM #{table}"
508
554
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
509
555
  drv.execute(sql, filter.values)
556
+ autocommit_standalone_write(drv)
510
557
  { success: true }
511
558
  end
512
559
 
@@ -534,12 +581,29 @@ module Tina4
534
581
  @driver_name
535
582
  end
536
583
 
537
- # Execute a write statement. Returns true/false for simple writes.
538
- # Returns DatabaseResult if SQL contains RETURNING, CALL, EXEC, or SELECT.
584
+ # Execute a write statement. FAILS LOUD raises on a SQL error.
585
+ #
586
+ # On a SQL error (bad SQL, constraint violation, dead/aborted connection,
587
+ # missing driver) the cause is captured on @last_error / #get_error AND the
588
+ # error is re-raised — execute() never silently returns false on failure.
589
+ # Almost no caller checks a boolean after every write, so the old
590
+ # swallow-and-return-false behaviour turned a failed INSERT/UPDATE/DELETE
591
+ # into a silent partial-write footgun. This mirrors fetch()/fetch_one(),
592
+ # which already raise, and the Python master (database.execute).
593
+ #
594
+ # On SUCCESS the return is unchanged: a DatabaseResult when the SQL contains
595
+ # RETURNING, CALL, EXEC, or SELECT (truthy), otherwise true. Never false.
596
+ #
597
+ # Higher-level callers that promise a boolean (ORM save/create_table) wrap
598
+ # this in begin/rescue and return false themselves; the migration runner and
599
+ # dev-admin/MCP DB tools catch the raise and surface it as a failed migration
600
+ # or a clean { error: } payload respectively.
539
601
  def execute(sql, params = [])
540
602
  cache_invalidate if @cache_enabled
541
- result = current_driver.execute(sql, params)
603
+ drv = current_driver
604
+ result = drv.execute(sql, params)
542
605
  @last_error = nil
606
+ autocommit_standalone_write(drv)
543
607
  sql_upper = sql.strip.upcase
544
608
  if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
545
609
  sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
@@ -548,7 +612,7 @@ module Tina4
548
612
  true
549
613
  rescue => e
550
614
  @last_error = e.message
551
- false
615
+ raise
552
616
  end
553
617
 
554
618
  def execute_many(sql, params_list = [])
@@ -570,6 +634,7 @@ module Tina4
570
634
  def transaction
571
635
  drv = current_driver
572
636
  Thread.current[@tx_pin_key] = drv
637
+ Thread.current[@tx_depth_key] = 1
573
638
  drv.begin_transaction
574
639
  yield self
575
640
  drv.commit
@@ -578,29 +643,83 @@ module Tina4
578
643
  raise e
579
644
  ensure
580
645
  Thread.current[@tx_pin_key] = nil
646
+ Thread.current[@tx_depth_key] = nil
581
647
  end
582
648
 
583
649
  # Begin a transaction without a block — matches PHP/Python/Node API.
584
650
  # Pins the driver to this thread for the whole transaction so executes
585
651
  # and the final commit/rollback all run on the same connection.
652
+ #
653
+ # Nested-begin guard (v3.13.37, DB-contract C): a second start_transaction
654
+ # on a thread that already holds the pin is a double-begin — the inner BEGIN
655
+ # silently commits or no-ops on most engines, leaving the connection
656
+ # mid-transaction with the caller none the wiser. We keep a per-thread depth
657
+ # counter and log a clear warning instead of silently re-beginning. The pin
658
+ # stays on the original driver so commit/rollback still land on the right
659
+ # connection.
586
660
  def start_transaction
661
+ pinned = Thread.current[@tx_pin_key]
662
+ if pinned
663
+ depth = (Thread.current[@tx_depth_key] || 1)
664
+ Tina4::Log.warning(
665
+ "start_transaction called while a transaction is already open on this " \
666
+ "thread (depth would become #{depth + 1}). Nested transactions are not " \
667
+ "supported — the existing transaction stays open on its pinned " \
668
+ "connection and this nested begin is ignored. Commit or rollback the " \
669
+ "outer transaction first."
670
+ )
671
+ Thread.current[@tx_depth_key] = depth + 1
672
+ return
673
+ end
587
674
  drv = current_driver
588
675
  Thread.current[@tx_pin_key] = drv
676
+ Thread.current[@tx_depth_key] = 1
589
677
  drv.begin_transaction
590
678
  end
591
679
 
592
680
  # Commit the current transaction and release the driver pin.
681
+ #
682
+ # FAILS LOUD (v3.13.37, DB-contract C): if the underlying commit raises,
683
+ # capture @last_error and RE-RAISE — never swallow. On failure the
684
+ # transaction pin is RETAINED so the caller's follow-up #rollback lands on
685
+ # the SAME connection (clearing it would leak a dirty connection back into
686
+ # the pool and route the rollback to a different one). The pin is cleared
687
+ # ONLY on a successful commit. An inner commit of an ignored nested begin
688
+ # (depth > 1) just decrements the depth and returns — the outer commit is
689
+ # the real one.
593
690
  def commit
691
+ depth = (Thread.current[@tx_depth_key] || 0)
692
+ if depth > 1
693
+ Thread.current[@tx_depth_key] = depth - 1
694
+ return
695
+ end
594
696
  current_driver.commit
595
- ensure
697
+ @last_error = nil
698
+ # Success — release the pin.
596
699
  Thread.current[@tx_pin_key] = nil
700
+ Thread.current[@tx_depth_key] = nil
701
+ rescue => e
702
+ # Keep the pin so rollback reaches this same connection.
703
+ @last_error = e.message
704
+ raise
597
705
  end
598
706
 
599
707
  # Roll back the current transaction and release the driver pin.
708
+ #
709
+ # Rollback is the terminal cleanup of a transaction, so it ALWAYS clears the
710
+ # pin (and the depth counter) — even after a failed commit it routes to the
711
+ # retained pinned connection and cleans it up. If the underlying rollback
712
+ # itself raises, @last_error is captured and the error re-raised, but the pin
713
+ # is still released so a poisoned connection doesn't stay pinned forever.
600
714
  def rollback
601
715
  current_driver.rollback
716
+ @last_error = nil
717
+ rescue => e
718
+ @last_error = e.message
719
+ raise
602
720
  ensure
603
721
  Thread.current[@tx_pin_key] = nil
722
+ Thread.current[@tx_depth_key] = nil
604
723
  end
605
724
 
606
725
  def tables
@@ -733,6 +852,46 @@ module Tina4
733
852
 
734
853
  private
735
854
 
855
+ # Run a fetch straight against the driver — no cache lookup or store.
856
+ #
857
+ # DB-contract A (v3.13.37): shared by the cached and no_cache paths so error
858
+ # capture is identical regardless of caching. FAILS LOUD: a SQL error in the
859
+ # main query propagates (same contract as #execute). The cause is captured on
860
+ # @last_error for #get_error before the re-raise — preferring the driver's own
861
+ # last_error (when it exposes one, e.g. postgres) over the exception message.
862
+ def fetch_direct(drv, effective_sql, params)
863
+ result = drv.execute_query(effective_sql, params)
864
+ @last_error = nil
865
+ Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
866
+ rescue => e
867
+ @last_error = driver_error_message(drv, e)
868
+ raise
869
+ end
870
+
871
+ # Run a fetch_one straight against the driver — no cache lookup or store.
872
+ #
873
+ # DB-contract A (v3.13.37): goes through #fetch (so the trailing-semicolon
874
+ # strip + LIMIT-append + driver path stay identical), but wraps it so the
875
+ # error is captured on @last_error and re-raised. Returns the first row (a
876
+ # Hash) or nil on a SUCCESSFUL "no row" read.
877
+ def fetch_one_direct(sql, params)
878
+ result = fetch(sql, params, limit: 1, no_cache: true)
879
+ @last_error = nil
880
+ result.first
881
+ rescue => e
882
+ @last_error = driver_error_message(current_driver, e)
883
+ raise
884
+ end
885
+
886
+ # Prefer the driver's own last_error (postgres sets one) over str(e), so the
887
+ # captured message matches what each engine surfaces; never blank.
888
+ def driver_error_message(drv, error)
889
+ drv_err = drv.respond_to?(:last_error) ? drv.last_error : nil
890
+ msg = drv_err || error.message
891
+ msg = error.message if msg.nil? || msg.to_s.empty?
892
+ msg || @last_error
893
+ end
894
+
736
895
  # Ensure the tina4_sequences table exists for race-safe ID generation.
737
896
  def ensure_sequence_table
738
897
  return if table_exists?("tina4_sequences")
@@ -747,41 +906,212 @@ module Tina4
747
906
  end
748
907
 
749
908
  # Atomically increment and return the next value for a named sequence.
750
- # Seeds from MAX(pk_column) on first use so existing data is respected.
909
+ #
910
+ # DB-contract B (v3.13.37): the old read-increment-read path had a RACE —
911
+ # two concurrent callers could read the same current_value and return the
912
+ # same id (duplicate primary keys). Each engine now uses a single atomic
913
+ # increment-and-return, pinned to ONE driver so the two statements (where two
914
+ # are needed) land on the same connection:
915
+ #
916
+ # * SQLite (lib >= 3.35): UPDATE ... SET current_value = current_value + 1
917
+ # WHERE seq_name = ? RETURNING current_value — one atomic statement, run
918
+ # under the process-wide SqliteDriver.write_lock. Older SQLite falls back
919
+ # to UPDATE +1 then SELECT, still serialised by the held write_lock.
920
+ # * MySQL: UPDATE ... SET current_value = LAST_INSERT_ID(current_value + 1)
921
+ # then SELECT LAST_INSERT_ID() on the SAME connection (per-connection →
922
+ # race-safe).
923
+ # * MSSQL: UPDATE ... SET current_value += 1 OUTPUT inserted.current_value
924
+ # WHERE seq_name = ? — one atomic statement.
925
+ #
926
+ # Seeding is race-safe: an atomic insert-if-absent (INSERT OR IGNORE /
927
+ # INSERT IGNORE / INSERT ... WHERE NOT EXISTS) seeded from MAX(pk) runs BEFORE
928
+ # the atomic increment, so there is never a read-then-insert gap. On error we
929
+ # RAISE (never silently fall back to 1).
751
930
  def sequence_next(seq_name, table: nil, pk_column: "id")
752
- ensure_sequence_table
931
+ # Pin a single driver for the whole sequence op so seed + increment + read
932
+ # all hit the SAME connection. Inside an active transaction the driver is
933
+ # already pinned; otherwise pin here and release in the ensure so the pool
934
+ # can rotate afterwards.
935
+ already_pinned = !Thread.current[@tx_pin_key].nil?
753
936
  drv = current_driver
937
+ Thread.current[@tx_pin_key] = drv unless already_pinned
754
938
 
755
- # Check if the sequence key already exists
756
- rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
757
- row = rows.is_a?(Array) ? rows.first : nil
939
+ begin
940
+ case @driver_name
941
+ when "sqlite"
942
+ # SQLite does ensure-table + seed + increment all under the driver
943
+ # write lock (a single shared connection — concurrent reads/writes on
944
+ # it otherwise corrupt or race).
945
+ sequence_next_sqlite(drv, seq_name, table, pk_column)
946
+ when "mysql"
947
+ ensure_sequence_table
948
+ sequence_next_mysql(drv, seq_name, table, pk_column)
949
+ when "mssql"
950
+ ensure_sequence_table
951
+ sequence_next_mssql(drv, seq_name, table, pk_column)
952
+ else
953
+ # Any other engine routed here (defensive) — generic atomic-ish path.
954
+ ensure_sequence_table
955
+ sequence_next_generic(drv, seq_name, table, pk_column)
956
+ end
957
+ ensure
958
+ Thread.current[@tx_pin_key] = nil unless already_pinned
959
+ end
960
+ end
758
961
 
759
- if row.nil?
760
- # Seed from MAX(pk_column) if table data exists
761
- seed_value = 0
762
- if table
763
- begin
764
- max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
765
- max_row = max_rows.is_a?(Array) ? max_rows.first : nil
766
- val = row_value(max_row, :max_id)
767
- seed_value = val.to_i if val
768
- rescue
769
- # Table may not exist yet — start from 0
770
- end
962
+ # Best-effort MAX(pk) seed for a new sequence row. 0 if table missing/empty.
963
+ def sequence_seed_value(drv, table, pk_column)
964
+ return 0 unless table
965
+
966
+ max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
967
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
968
+ val = row_value(max_row, :max_id)
969
+ val ? val.to_i : 0
970
+ rescue StandardError
971
+ 0 # Table doesn't exist yet — start at 0
972
+ end
973
+
974
+ # SQLite atomic increment. Holds the process-wide write lock for the ENTIRE
975
+ # op (ensure-table + seed + increment). The single UPDATE ... RETURNING (lib
976
+ # >= 3.35) is itself atomic; the held lock serialises every connection touch
977
+ # so no duplicate ids under concurrency. ensure-table is done inline under the
978
+ # lock (NOT via ensure_sequence_table, which would re-enter current_driver/
979
+ # table_exists? and risk a nested touch).
980
+ def sequence_next_sqlite(drv, seq_name, table, pk_column)
981
+ conn = drv.respond_to?(:connection) ? drv.connection : nil
982
+ raise "get_next_id: SQLite driver has no live connection" if conn.nil?
983
+
984
+ Tina4::Drivers::SqliteDriver.write_lock.synchronize do
985
+ # Ensure the sequence table exists (idempotent) on this connection.
986
+ conn.execute(
987
+ "CREATE TABLE IF NOT EXISTS tina4_sequences (" \
988
+ "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " \
989
+ "current_value INTEGER NOT NULL DEFAULT 0)"
990
+ )
991
+ seed = sequence_seed_value(drv, table, pk_column)
992
+ conn.execute(
993
+ "INSERT OR IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
994
+ [seq_name, seed]
995
+ )
996
+
997
+ if sqlite_supports_returning?
998
+ # One atomic increment-and-return.
999
+ rows = conn.execute(
1000
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " \
1001
+ "WHERE seq_name = ? RETURNING current_value",
1002
+ [seq_name]
1003
+ )
1004
+ row = rows.is_a?(Array) ? rows.first : nil
1005
+ else
1006
+ # Older SQLite (< 3.35, no RETURNING): increment then read. Still
1007
+ # race-safe because we hold write_lock across both statements.
1008
+ conn.execute(
1009
+ "UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
1010
+ [seq_name]
1011
+ )
1012
+ rows = conn.execute(
1013
+ "SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
1014
+ [seq_name]
1015
+ )
1016
+ row = rows.is_a?(Array) ? rows.first : nil
771
1017
  end
772
- drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
773
- drv.commit rescue nil
1018
+
1019
+ val = sqlite_row_value(row, "current_value")
1020
+ raise "get_next_id: sequence row '#{seq_name}' vanished mid-increment" if val.nil?
1021
+
1022
+ val.to_i
774
1023
  end
1024
+ end
775
1025
 
776
- # Atomic increment
777
- drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
1026
+ # MySQL atomic increment. LAST_INSERT_ID(expr) stashes expr in this
1027
+ # CONNECTION's session var and returns it atomic per-connection, no
1028
+ # read-back race. Calls the driver directly (not self.commit) so it doesn't
1029
+ # trip Database#commit's pin management.
1030
+ def sequence_next_mysql(drv, seq_name, table, pk_column)
1031
+ seed = sequence_seed_value(drv, table, pk_column)
1032
+ # Race-safe seed: INSERT IGNORE is a no-op if the row exists.
1033
+ drv.execute("INSERT IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
1034
+ drv.commit rescue nil
1035
+ drv.execute(
1036
+ "UPDATE tina4_sequences SET current_value = LAST_INSERT_ID(current_value + 1) WHERE seq_name = ?",
1037
+ [seq_name]
1038
+ )
778
1039
  drv.commit rescue nil
1040
+ rows = drv.execute_query("SELECT LAST_INSERT_ID() AS next_id")
1041
+ row = rows.is_a?(Array) ? rows.first : nil
1042
+ val = row_value(row, :next_id)
1043
+ raise "get_next_id: LAST_INSERT_ID() returned nothing for '#{seq_name}'" if val.nil?
779
1044
 
780
- # Read back the incremented value
1045
+ val.to_i
1046
+ end
1047
+
1048
+ # MSSQL atomic increment via a single UPDATE ... OUTPUT statement.
1049
+ def sequence_next_mssql(drv, seq_name, table, pk_column)
1050
+ seed = sequence_seed_value(drv, table, pk_column)
1051
+ # Race-safe seed: INSERT only when absent (single statement).
1052
+ drv.execute(
1053
+ "INSERT INTO tina4_sequences (seq_name, current_value) " \
1054
+ "SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM tina4_sequences WHERE seq_name = ?)",
1055
+ [seq_name, seed, seq_name]
1056
+ )
1057
+ drv.commit rescue nil
1058
+ # Single atomic statement: increment + return the new value via OUTPUT.
1059
+ rows = drv.execute_query(
1060
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " \
1061
+ "OUTPUT inserted.current_value AS next_id WHERE seq_name = ?",
1062
+ [seq_name]
1063
+ )
1064
+ drv.commit rescue nil
1065
+ row = rows.is_a?(Array) ? rows.first : nil
1066
+ val = row_value(row, :next_id)
1067
+ raise "get_next_id: OUTPUT produced no row for sequence '#{seq_name}'" if val.nil?
1068
+
1069
+ val.to_i
1070
+ end
1071
+
1072
+ # Defensive fallback for any engine not otherwise special-cased: seed if
1073
+ # absent (rollback on conflict), increment, then read on the pinned driver.
1074
+ def sequence_next_generic(drv, seq_name, table, pk_column)
1075
+ seed = sequence_seed_value(drv, table, pk_column)
1076
+ begin
1077
+ drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
1078
+ drv.commit rescue nil
1079
+ rescue StandardError
1080
+ # Row likely already exists (PK conflict) — fine, keep going.
1081
+ drv.rollback rescue nil
1082
+ end
1083
+ drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
1084
+ drv.commit rescue nil
781
1085
  rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
782
1086
  row = rows.is_a?(Array) ? rows.first : nil
783
1087
  val = row_value(row, :current_value)
784
- val ? val.to_i : 1
1088
+ raise "get_next_id: sequence row '#{seq_name}' missing" if val.nil?
1089
+
1090
+ val.to_i
1091
+ end
1092
+
1093
+ # Whether the loaded SQLite library supports the RETURNING clause (>= 3.35).
1094
+ def sqlite_supports_returning?
1095
+ return @sqlite_returning unless @sqlite_returning.nil?
1096
+
1097
+ ver = (defined?(SQLite3::SQLITE_VERSION) && SQLite3::SQLITE_VERSION) || "0.0.0"
1098
+ parts = ver.split(".").map(&:to_i)
1099
+ major, minor = parts[0].to_i, parts[1].to_i
1100
+ @sqlite_returning = (major > 3) || (major == 3 && minor >= 35)
1101
+ rescue StandardError
1102
+ @sqlite_returning = false
1103
+ end
1104
+
1105
+ # Read a value from a raw sqlite3 row (results_as_hash → string keys; a bare
1106
+ # RETURNING row may come back as a positional Array on some gem versions).
1107
+ def sqlite_row_value(row, key)
1108
+ return nil if row.nil?
1109
+
1110
+ if row.is_a?(Hash)
1111
+ row[key] || row[key.to_sym] || row.values.first
1112
+ elsif row.is_a?(Array)
1113
+ row.first
1114
+ end
785
1115
  end
786
1116
 
787
1117
  # Safely extract a value from a driver result row, trying both symbol and string keys.
@@ -794,6 +1124,31 @@ module Tina4
794
1124
  %w[true 1 yes on].include?((val || "").to_s.strip.downcase)
795
1125
  end
796
1126
 
1127
+ # Durability: commit a standalone write so it actually persists.
1128
+ #
1129
+ # Called after a write (execute/insert/update/delete) issued OUTSIDE an
1130
+ # explicit transaction. The commit is suppressed when autocommit is off
1131
+ # (TINA4_AUTOCOMMIT=false, strict manual mode) OR when a transaction is open
1132
+ # on this thread (the thread tx-pin is set) — so an explicit
1133
+ # start_transaction()/commit() block stays atomic and is never broken up by
1134
+ # a per-statement commit. A commit with no transaction in progress is a
1135
+ # harmless no-op on every engine (SQLite swallows the specific
1136
+ # "no transaction is active" error in its driver; PostgreSQL/MySQL/MSSQL emit
1137
+ # at most a benign warning), so this never raises in the common case. Mirrors
1138
+ # the `not self._in_transaction and self.autocommit` gate in the Python
1139
+ # master and PHP's `autoCommit && transaction === null`.
1140
+ def autocommit_standalone_write(drv)
1141
+ return unless @autocommit
1142
+ return unless Thread.current[@tx_pin_key].nil?
1143
+
1144
+ drv.commit
1145
+ rescue StandardError => e
1146
+ # A standalone write already succeeded; a follow-up commit failure here
1147
+ # must not mask that. Capture for #get_error and log, but don't raise.
1148
+ @last_error = e.message
1149
+ Tina4::Log.warning("autocommit commit after standalone write failed: #{e.message}")
1150
+ end
1151
+
797
1152
  # "persistent" / "request" / "off" — mirrors Python connection.py.
798
1153
  def cache_mode
799
1154
  if @cache_persistent