tina4ruby 3.13.36 → 3.13.38

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
@@ -374,6 +387,14 @@ module Tina4
374
387
 
375
388
  # Fetch rows with pagination, returning a DatabaseResult.
376
389
  #
390
+ # FAILS LOUD (v3.13.37, DB-contract A): a SQL error in the main query
391
+ # propagates — a typo'd / bad SELECT RAISES, it never silently returns an
392
+ # empty result. The cause is captured on @last_error / #get_error before the
393
+ # re-raise (parity with #execute and the Python master), so the public API
394
+ # can read why it failed even for engines whose driver doesn't expose its
395
+ # own last_error. Because the raise happens BEFORE cache_set is reached, a
396
+ # buried failure is never written into the query cache.
397
+ #
377
398
  # Pass `no_cache: true` to bypass the query cache entirely for this single
378
399
  # call — no lookup, no store — and run the query directly against the
379
400
  # driver. Works for both the request-scoped auto-cache and the persistent
@@ -404,22 +425,30 @@ module Tina4
404
425
  @cache_mutex.synchronize { @cache_hits += 1 }
405
426
  return cached
406
427
  end
407
- result = drv.execute_query(effective_sql, params)
408
- result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
428
+ # fetch_direct RAISES on a SQL error (and captures @last_error), so a
429
+ # failed read never reaches cache_set below — we never cache an empty
430
+ # result produced by a buried failure.
431
+ result = fetch_direct(drv, effective_sql, params)
409
432
  cache_set(key, result)
410
433
  @cache_mutex.synchronize { @cache_misses += 1 }
411
434
  return result
412
435
  end
413
436
 
414
- rows = drv.execute_query(effective_sql, params)
415
- Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
437
+ fetch_direct(drv, effective_sql, params)
416
438
  end
417
439
 
418
440
  # Fetch a single row (or nil).
419
441
  #
442
+ # FAILS LOUD (v3.13.37, DB-contract A): a SQL error RAISES and populates
443
+ # @last_error / #get_error the same way #execute and #fetch do — pre-fix
444
+ # fetch_one ran the query through #fetch but did not separately guarantee the
445
+ # error capture, and a buried failure could be cached as nil. It now routes
446
+ # the uncached path through fetch_one_direct (capture + re-raise) and, on the
447
+ # cached path, only ever stores a value produced by a SUCCESSFUL read.
448
+ #
420
449
  # Pass `no_cache: true` to bypass the query cache entirely for this call —
421
450
  # 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
451
+ # propagated to the inner read so the request-scoped/persistent cache is
423
452
  # never populated either. Default `false` preserves cached behaviour.
424
453
  def fetch_one(sql, params = [], no_cache: false)
425
454
  sql = Tina4::Database.strip_trailing_semicolons(sql)
@@ -430,15 +459,15 @@ module Tina4
430
459
  @cache_mutex.synchronize { @cache_hits += 1 }
431
460
  return cached
432
461
  end
433
- result = fetch(sql, params, limit: 1)
434
- value = result.first
462
+ # Raises (and captures @last_error) BEFORE cache_set, so a failed read
463
+ # is never cached as nil.
464
+ value = fetch_one_direct(sql, params)
435
465
  cache_set(key, value)
436
466
  @cache_mutex.synchronize { @cache_misses += 1 }
437
467
  return value
438
468
  end
439
469
 
440
- result = fetch(sql, params, limit: 1, no_cache: no_cache)
441
- result.first
470
+ fetch_one_direct(sql, params)
442
471
  end
443
472
 
444
473
  def insert(table, data)
@@ -534,8 +563,23 @@ module Tina4
534
563
  @driver_name
535
564
  end
536
565
 
537
- # Execute a write statement. Returns true/false for simple writes.
538
- # Returns DatabaseResult if SQL contains RETURNING, CALL, EXEC, or SELECT.
566
+ # Execute a write statement. FAILS LOUD raises on a SQL error.
567
+ #
568
+ # On a SQL error (bad SQL, constraint violation, dead/aborted connection,
569
+ # missing driver) the cause is captured on @last_error / #get_error AND the
570
+ # error is re-raised — execute() never silently returns false on failure.
571
+ # Almost no caller checks a boolean after every write, so the old
572
+ # swallow-and-return-false behaviour turned a failed INSERT/UPDATE/DELETE
573
+ # into a silent partial-write footgun. This mirrors fetch()/fetch_one(),
574
+ # which already raise, and the Python master (database.execute).
575
+ #
576
+ # On SUCCESS the return is unchanged: a DatabaseResult when the SQL contains
577
+ # RETURNING, CALL, EXEC, or SELECT (truthy), otherwise true. Never false.
578
+ #
579
+ # Higher-level callers that promise a boolean (ORM save/create_table) wrap
580
+ # this in begin/rescue and return false themselves; the migration runner and
581
+ # dev-admin/MCP DB tools catch the raise and surface it as a failed migration
582
+ # or a clean { error: } payload respectively.
539
583
  def execute(sql, params = [])
540
584
  cache_invalidate if @cache_enabled
541
585
  result = current_driver.execute(sql, params)
@@ -548,7 +592,7 @@ module Tina4
548
592
  true
549
593
  rescue => e
550
594
  @last_error = e.message
551
- false
595
+ raise
552
596
  end
553
597
 
554
598
  def execute_many(sql, params_list = [])
@@ -570,6 +614,7 @@ module Tina4
570
614
  def transaction
571
615
  drv = current_driver
572
616
  Thread.current[@tx_pin_key] = drv
617
+ Thread.current[@tx_depth_key] = 1
573
618
  drv.begin_transaction
574
619
  yield self
575
620
  drv.commit
@@ -578,29 +623,83 @@ module Tina4
578
623
  raise e
579
624
  ensure
580
625
  Thread.current[@tx_pin_key] = nil
626
+ Thread.current[@tx_depth_key] = nil
581
627
  end
582
628
 
583
629
  # Begin a transaction without a block — matches PHP/Python/Node API.
584
630
  # Pins the driver to this thread for the whole transaction so executes
585
631
  # and the final commit/rollback all run on the same connection.
632
+ #
633
+ # Nested-begin guard (v3.13.37, DB-contract C): a second start_transaction
634
+ # on a thread that already holds the pin is a double-begin — the inner BEGIN
635
+ # silently commits or no-ops on most engines, leaving the connection
636
+ # mid-transaction with the caller none the wiser. We keep a per-thread depth
637
+ # counter and log a clear warning instead of silently re-beginning. The pin
638
+ # stays on the original driver so commit/rollback still land on the right
639
+ # connection.
586
640
  def start_transaction
641
+ pinned = Thread.current[@tx_pin_key]
642
+ if pinned
643
+ depth = (Thread.current[@tx_depth_key] || 1)
644
+ Tina4::Log.warning(
645
+ "start_transaction called while a transaction is already open on this " \
646
+ "thread (depth would become #{depth + 1}). Nested transactions are not " \
647
+ "supported — the existing transaction stays open on its pinned " \
648
+ "connection and this nested begin is ignored. Commit or rollback the " \
649
+ "outer transaction first."
650
+ )
651
+ Thread.current[@tx_depth_key] = depth + 1
652
+ return
653
+ end
587
654
  drv = current_driver
588
655
  Thread.current[@tx_pin_key] = drv
656
+ Thread.current[@tx_depth_key] = 1
589
657
  drv.begin_transaction
590
658
  end
591
659
 
592
660
  # Commit the current transaction and release the driver pin.
661
+ #
662
+ # FAILS LOUD (v3.13.37, DB-contract C): if the underlying commit raises,
663
+ # capture @last_error and RE-RAISE — never swallow. On failure the
664
+ # transaction pin is RETAINED so the caller's follow-up #rollback lands on
665
+ # the SAME connection (clearing it would leak a dirty connection back into
666
+ # the pool and route the rollback to a different one). The pin is cleared
667
+ # ONLY on a successful commit. An inner commit of an ignored nested begin
668
+ # (depth > 1) just decrements the depth and returns — the outer commit is
669
+ # the real one.
593
670
  def commit
671
+ depth = (Thread.current[@tx_depth_key] || 0)
672
+ if depth > 1
673
+ Thread.current[@tx_depth_key] = depth - 1
674
+ return
675
+ end
594
676
  current_driver.commit
595
- ensure
677
+ @last_error = nil
678
+ # Success — release the pin.
596
679
  Thread.current[@tx_pin_key] = nil
680
+ Thread.current[@tx_depth_key] = nil
681
+ rescue => e
682
+ # Keep the pin so rollback reaches this same connection.
683
+ @last_error = e.message
684
+ raise
597
685
  end
598
686
 
599
687
  # Roll back the current transaction and release the driver pin.
688
+ #
689
+ # Rollback is the terminal cleanup of a transaction, so it ALWAYS clears the
690
+ # pin (and the depth counter) — even after a failed commit it routes to the
691
+ # retained pinned connection and cleans it up. If the underlying rollback
692
+ # itself raises, @last_error is captured and the error re-raised, but the pin
693
+ # is still released so a poisoned connection doesn't stay pinned forever.
600
694
  def rollback
601
695
  current_driver.rollback
696
+ @last_error = nil
697
+ rescue => e
698
+ @last_error = e.message
699
+ raise
602
700
  ensure
603
701
  Thread.current[@tx_pin_key] = nil
702
+ Thread.current[@tx_depth_key] = nil
604
703
  end
605
704
 
606
705
  def tables
@@ -733,6 +832,46 @@ module Tina4
733
832
 
734
833
  private
735
834
 
835
+ # Run a fetch straight against the driver — no cache lookup or store.
836
+ #
837
+ # DB-contract A (v3.13.37): shared by the cached and no_cache paths so error
838
+ # capture is identical regardless of caching. FAILS LOUD: a SQL error in the
839
+ # main query propagates (same contract as #execute). The cause is captured on
840
+ # @last_error for #get_error before the re-raise — preferring the driver's own
841
+ # last_error (when it exposes one, e.g. postgres) over the exception message.
842
+ def fetch_direct(drv, effective_sql, params)
843
+ result = drv.execute_query(effective_sql, params)
844
+ @last_error = nil
845
+ Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
846
+ rescue => e
847
+ @last_error = driver_error_message(drv, e)
848
+ raise
849
+ end
850
+
851
+ # Run a fetch_one straight against the driver — no cache lookup or store.
852
+ #
853
+ # DB-contract A (v3.13.37): goes through #fetch (so the trailing-semicolon
854
+ # strip + LIMIT-append + driver path stay identical), but wraps it so the
855
+ # error is captured on @last_error and re-raised. Returns the first row (a
856
+ # Hash) or nil on a SUCCESSFUL "no row" read.
857
+ def fetch_one_direct(sql, params)
858
+ result = fetch(sql, params, limit: 1, no_cache: true)
859
+ @last_error = nil
860
+ result.first
861
+ rescue => e
862
+ @last_error = driver_error_message(current_driver, e)
863
+ raise
864
+ end
865
+
866
+ # Prefer the driver's own last_error (postgres sets one) over str(e), so the
867
+ # captured message matches what each engine surfaces; never blank.
868
+ def driver_error_message(drv, error)
869
+ drv_err = drv.respond_to?(:last_error) ? drv.last_error : nil
870
+ msg = drv_err || error.message
871
+ msg = error.message if msg.nil? || msg.to_s.empty?
872
+ msg || @last_error
873
+ end
874
+
736
875
  # Ensure the tina4_sequences table exists for race-safe ID generation.
737
876
  def ensure_sequence_table
738
877
  return if table_exists?("tina4_sequences")
@@ -747,41 +886,212 @@ module Tina4
747
886
  end
748
887
 
749
888
  # 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.
889
+ #
890
+ # DB-contract B (v3.13.37): the old read-increment-read path had a RACE —
891
+ # two concurrent callers could read the same current_value and return the
892
+ # same id (duplicate primary keys). Each engine now uses a single atomic
893
+ # increment-and-return, pinned to ONE driver so the two statements (where two
894
+ # are needed) land on the same connection:
895
+ #
896
+ # * SQLite (lib >= 3.35): UPDATE ... SET current_value = current_value + 1
897
+ # WHERE seq_name = ? RETURNING current_value — one atomic statement, run
898
+ # under the process-wide SqliteDriver.write_lock. Older SQLite falls back
899
+ # to UPDATE +1 then SELECT, still serialised by the held write_lock.
900
+ # * MySQL: UPDATE ... SET current_value = LAST_INSERT_ID(current_value + 1)
901
+ # then SELECT LAST_INSERT_ID() on the SAME connection (per-connection →
902
+ # race-safe).
903
+ # * MSSQL: UPDATE ... SET current_value += 1 OUTPUT inserted.current_value
904
+ # WHERE seq_name = ? — one atomic statement.
905
+ #
906
+ # Seeding is race-safe: an atomic insert-if-absent (INSERT OR IGNORE /
907
+ # INSERT IGNORE / INSERT ... WHERE NOT EXISTS) seeded from MAX(pk) runs BEFORE
908
+ # the atomic increment, so there is never a read-then-insert gap. On error we
909
+ # RAISE (never silently fall back to 1).
751
910
  def sequence_next(seq_name, table: nil, pk_column: "id")
752
- ensure_sequence_table
911
+ # Pin a single driver for the whole sequence op so seed + increment + read
912
+ # all hit the SAME connection. Inside an active transaction the driver is
913
+ # already pinned; otherwise pin here and release in the ensure so the pool
914
+ # can rotate afterwards.
915
+ already_pinned = !Thread.current[@tx_pin_key].nil?
753
916
  drv = current_driver
917
+ Thread.current[@tx_pin_key] = drv unless already_pinned
754
918
 
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
919
+ begin
920
+ case @driver_name
921
+ when "sqlite"
922
+ # SQLite does ensure-table + seed + increment all under the driver
923
+ # write lock (a single shared connection — concurrent reads/writes on
924
+ # it otherwise corrupt or race).
925
+ sequence_next_sqlite(drv, seq_name, table, pk_column)
926
+ when "mysql"
927
+ ensure_sequence_table
928
+ sequence_next_mysql(drv, seq_name, table, pk_column)
929
+ when "mssql"
930
+ ensure_sequence_table
931
+ sequence_next_mssql(drv, seq_name, table, pk_column)
932
+ else
933
+ # Any other engine routed here (defensive) — generic atomic-ish path.
934
+ ensure_sequence_table
935
+ sequence_next_generic(drv, seq_name, table, pk_column)
936
+ end
937
+ ensure
938
+ Thread.current[@tx_pin_key] = nil unless already_pinned
939
+ end
940
+ end
758
941
 
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
942
+ # Best-effort MAX(pk) seed for a new sequence row. 0 if table missing/empty.
943
+ def sequence_seed_value(drv, table, pk_column)
944
+ return 0 unless table
945
+
946
+ max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
947
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
948
+ val = row_value(max_row, :max_id)
949
+ val ? val.to_i : 0
950
+ rescue StandardError
951
+ 0 # Table doesn't exist yet — start at 0
952
+ end
953
+
954
+ # SQLite atomic increment. Holds the process-wide write lock for the ENTIRE
955
+ # op (ensure-table + seed + increment). The single UPDATE ... RETURNING (lib
956
+ # >= 3.35) is itself atomic; the held lock serialises every connection touch
957
+ # so no duplicate ids under concurrency. ensure-table is done inline under the
958
+ # lock (NOT via ensure_sequence_table, which would re-enter current_driver/
959
+ # table_exists? and risk a nested touch).
960
+ def sequence_next_sqlite(drv, seq_name, table, pk_column)
961
+ conn = drv.respond_to?(:connection) ? drv.connection : nil
962
+ raise "get_next_id: SQLite driver has no live connection" if conn.nil?
963
+
964
+ Tina4::Drivers::SqliteDriver.write_lock.synchronize do
965
+ # Ensure the sequence table exists (idempotent) on this connection.
966
+ conn.execute(
967
+ "CREATE TABLE IF NOT EXISTS tina4_sequences (" \
968
+ "seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " \
969
+ "current_value INTEGER NOT NULL DEFAULT 0)"
970
+ )
971
+ seed = sequence_seed_value(drv, table, pk_column)
972
+ conn.execute(
973
+ "INSERT OR IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
974
+ [seq_name, seed]
975
+ )
976
+
977
+ if sqlite_supports_returning?
978
+ # One atomic increment-and-return.
979
+ rows = conn.execute(
980
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " \
981
+ "WHERE seq_name = ? RETURNING current_value",
982
+ [seq_name]
983
+ )
984
+ row = rows.is_a?(Array) ? rows.first : nil
985
+ else
986
+ # Older SQLite (< 3.35, no RETURNING): increment then read. Still
987
+ # race-safe because we hold write_lock across both statements.
988
+ conn.execute(
989
+ "UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
990
+ [seq_name]
991
+ )
992
+ rows = conn.execute(
993
+ "SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
994
+ [seq_name]
995
+ )
996
+ row = rows.is_a?(Array) ? rows.first : nil
771
997
  end
772
- drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
773
- drv.commit rescue nil
998
+
999
+ val = sqlite_row_value(row, "current_value")
1000
+ raise "get_next_id: sequence row '#{seq_name}' vanished mid-increment" if val.nil?
1001
+
1002
+ val.to_i
774
1003
  end
1004
+ end
775
1005
 
776
- # Atomic increment
777
- drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
1006
+ # MySQL atomic increment. LAST_INSERT_ID(expr) stashes expr in this
1007
+ # CONNECTION's session var and returns it atomic per-connection, no
1008
+ # read-back race. Calls the driver directly (not self.commit) so it doesn't
1009
+ # trip Database#commit's pin management.
1010
+ def sequence_next_mysql(drv, seq_name, table, pk_column)
1011
+ seed = sequence_seed_value(drv, table, pk_column)
1012
+ # Race-safe seed: INSERT IGNORE is a no-op if the row exists.
1013
+ drv.execute("INSERT IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
1014
+ drv.commit rescue nil
1015
+ drv.execute(
1016
+ "UPDATE tina4_sequences SET current_value = LAST_INSERT_ID(current_value + 1) WHERE seq_name = ?",
1017
+ [seq_name]
1018
+ )
778
1019
  drv.commit rescue nil
1020
+ rows = drv.execute_query("SELECT LAST_INSERT_ID() AS next_id")
1021
+ row = rows.is_a?(Array) ? rows.first : nil
1022
+ val = row_value(row, :next_id)
1023
+ raise "get_next_id: LAST_INSERT_ID() returned nothing for '#{seq_name}'" if val.nil?
1024
+
1025
+ val.to_i
1026
+ end
779
1027
 
780
- # Read back the incremented value
1028
+ # MSSQL atomic increment via a single UPDATE ... OUTPUT statement.
1029
+ def sequence_next_mssql(drv, seq_name, table, pk_column)
1030
+ seed = sequence_seed_value(drv, table, pk_column)
1031
+ # Race-safe seed: INSERT only when absent (single statement).
1032
+ drv.execute(
1033
+ "INSERT INTO tina4_sequences (seq_name, current_value) " \
1034
+ "SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM tina4_sequences WHERE seq_name = ?)",
1035
+ [seq_name, seed, seq_name]
1036
+ )
1037
+ drv.commit rescue nil
1038
+ # Single atomic statement: increment + return the new value via OUTPUT.
1039
+ rows = drv.execute_query(
1040
+ "UPDATE tina4_sequences SET current_value = current_value + 1 " \
1041
+ "OUTPUT inserted.current_value AS next_id WHERE seq_name = ?",
1042
+ [seq_name]
1043
+ )
1044
+ drv.commit rescue nil
1045
+ row = rows.is_a?(Array) ? rows.first : nil
1046
+ val = row_value(row, :next_id)
1047
+ raise "get_next_id: OUTPUT produced no row for sequence '#{seq_name}'" if val.nil?
1048
+
1049
+ val.to_i
1050
+ end
1051
+
1052
+ # Defensive fallback for any engine not otherwise special-cased: seed if
1053
+ # absent (rollback on conflict), increment, then read on the pinned driver.
1054
+ def sequence_next_generic(drv, seq_name, table, pk_column)
1055
+ seed = sequence_seed_value(drv, table, pk_column)
1056
+ begin
1057
+ drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed])
1058
+ drv.commit rescue nil
1059
+ rescue StandardError
1060
+ # Row likely already exists (PK conflict) — fine, keep going.
1061
+ drv.rollback rescue nil
1062
+ end
1063
+ drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
1064
+ drv.commit rescue nil
781
1065
  rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
782
1066
  row = rows.is_a?(Array) ? rows.first : nil
783
1067
  val = row_value(row, :current_value)
784
- val ? val.to_i : 1
1068
+ raise "get_next_id: sequence row '#{seq_name}' missing" if val.nil?
1069
+
1070
+ val.to_i
1071
+ end
1072
+
1073
+ # Whether the loaded SQLite library supports the RETURNING clause (>= 3.35).
1074
+ def sqlite_supports_returning?
1075
+ return @sqlite_returning unless @sqlite_returning.nil?
1076
+
1077
+ ver = (defined?(SQLite3::SQLITE_VERSION) && SQLite3::SQLITE_VERSION) || "0.0.0"
1078
+ parts = ver.split(".").map(&:to_i)
1079
+ major, minor = parts[0].to_i, parts[1].to_i
1080
+ @sqlite_returning = (major > 3) || (major == 3 && minor >= 35)
1081
+ rescue StandardError
1082
+ @sqlite_returning = false
1083
+ end
1084
+
1085
+ # Read a value from a raw sqlite3 row (results_as_hash → string keys; a bare
1086
+ # RETURNING row may come back as a positional Array on some gem versions).
1087
+ def sqlite_row_value(row, key)
1088
+ return nil if row.nil?
1089
+
1090
+ if row.is_a?(Hash)
1091
+ row[key] || row[key.to_sym] || row.values.first
1092
+ elsif row.is_a?(Array)
1093
+ row.first
1094
+ end
785
1095
  end
786
1096
 
787
1097
  # Safely extract a value from a driver result row, trying both symbol and string keys.
@@ -537,7 +537,11 @@ module Tina4
537
537
  body = read_json_body(env)
538
538
  table_name = (body && body["table"]) || ""
539
539
  count = (body && body["count"]) || 10
540
- json_response(seed_table_data(table_name, count.to_i))
540
+ seed = body && body["seed"]
541
+ seed = (Integer(seed) rescue nil) unless seed.nil?
542
+ clear = body && (body["clear"] == true || body["clear"].to_s == "true")
543
+ strict = body && (body["strict"] == true || body["strict"].to_s == "true")
544
+ json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
541
545
  when ["POST", "/__dev/api/tool"]
542
546
  body = read_json_body(env)
543
547
  tool = (body && body["tool"]) || ""
@@ -873,19 +877,21 @@ module Tina4
873
877
  end
874
878
  end
875
879
 
876
- # Execute all statements (single write or multi-statement batch)
880
+ # Execute all statements (single write or multi-statement batch).
881
+ # db.execute() now RAISES on a SQL error (it no longer returns false),
882
+ # so a bad statement is caught by the rescue below and surfaced as a
883
+ # clean { error: } payload — the dead "if result == false" check is
884
+ # gone. true is returned for plain writes; a DatabaseResult for
885
+ # RETURNING/CALL/EXEC (which carries affected_rows).
877
886
  total_affected = 0
878
887
  statements.each do |stmt|
879
888
  result = db.execute(stmt)
880
- if result == false
881
- return { error: db.get_error || "Statement failed: #{stmt}" }
882
- end
883
889
  total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
884
890
  end
885
891
 
886
892
  { affected: total_affected, success: true }
887
893
  rescue => e
888
- { error: e.message }
894
+ { error: db.get_error || e.message }
889
895
  end
890
896
  end
891
897
 
@@ -917,16 +923,27 @@ module Tina4
917
923
  end
918
924
  end
919
925
 
920
- def seed_table_data(table_name, count)
926
+ def seed_table_data(table_name, count, seed: nil, clear: false, strict: false)
921
927
  return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
922
928
 
923
929
  db = Tina4.database
924
930
  return { error: "No database configured" } unless db
925
931
 
926
932
  begin
927
- columns = db.columns(table_name)
928
- seeded = Tina4.seed_table(table_name, columns, count: count)
929
- { table: table_name, seeded: seeded }
933
+ # Delegate to the shared resilient seed_table helper so the endpoint
934
+ # gets the exact same per-row wrap (P1) no unhandled row failure can
935
+ # crash the endpoint plus clear/seed/strict (P2/P3). _normalize_columns
936
+ # skips auto-increment id PKs from the introspected column list.
937
+ summary = Tina4.seed_table(
938
+ table_name, db.columns(table_name),
939
+ count: count, seed: seed, clear: clear, strict: strict
940
+ )
941
+ {
942
+ table: table_name,
943
+ seeded: summary.seeded,
944
+ failed: summary.failed,
945
+ errors: summary.errors
946
+ }
930
947
  rescue => e
931
948
  { error: e.message }
932
949
  end
@@ -1392,6 +1409,32 @@ module Tina4
1392
1409
  { path: rel, branch: git[:branch], entries: entries, count: entries.size }
1393
1410
  end
1394
1411
 
1412
+ # Map a relative path to a CodeMirror language string. IDENTICAL in
1413
+ # coverage to the Python master (tina4_python/dev_admin lang_map). The
1414
+ # dev-admin SPA reads the "language" field to pick its grammar.
1415
+ def dev_admin_language(rel)
1416
+ base = File.basename(rel.to_s).downcase
1417
+ return "dockerfile" if %w[dockerfile dockerfile.dev dockerfile.prod].include?(base)
1418
+ return "env" if base == ".env" || base == ".env.example"
1419
+
1420
+ ext_map = {
1421
+ ".py" => "python", ".php" => "php", ".rb" => "ruby",
1422
+ ".ts" => "typescript", ".js" => "javascript", ".jsx" => "javascript",
1423
+ ".tsx" => "typescript", ".json" => "json", ".html" => "html",
1424
+ ".twig" => "html", ".css" => "css", ".scss" => "css",
1425
+ ".md" => "markdown", ".sql" => "sql", ".yaml" => "yaml",
1426
+ ".yml" => "yaml", ".toml" => "toml", ".xml" => "html",
1427
+ ".env" => "env",
1428
+ ".sh" => "shell", ".bash" => "shell",
1429
+ ".bat" => "shell", ".cmd" => "shell", ".ps1" => "shell",
1430
+ ".rs" => "rust", ".go" => "go", ".java" => "java",
1431
+ ".txt" => "text", ".csv" => "text", ".log" => "text",
1432
+ ".gemspec" => "ruby", ".rake" => "ruby",
1433
+ ".svg" => "svg"
1434
+ }
1435
+ ext_map.fetch(File.extname(base).downcase, "text")
1436
+ end
1437
+
1395
1438
  def file_read_payload(rel)
1396
1439
  return { error: "path required" } if rel.nil? || rel.empty?
1397
1440
  begin
@@ -1399,7 +1442,7 @@ module Tina4
1399
1442
  return { error: "Not found" } unless File.exist?(target)
1400
1443
  return { error: "Not a file" } unless File.file?(target)
1401
1444
  content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1402
- { path: rel, content: content, bytes: File.size(target) }
1445
+ { path: rel, content: content, bytes: File.size(target), language: dev_admin_language(rel) }
1403
1446
  rescue => e
1404
1447
  { error: e.message }
1405
1448
  end