tina4ruby 3.13.37 → 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.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +27 -10
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
data/lib/tina4/database.rb
CHANGED
|
@@ -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
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
|
|
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
|
|
213
|
-
# (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "
|
|
214
|
-
@cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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.
|
|
538
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
@@ -8,6 +8,18 @@ module Tina4
|
|
|
8
8
|
include SchemaSplit
|
|
9
9
|
attr_reader :connection
|
|
10
10
|
|
|
11
|
+
# Process-wide write lock — parity with Python's SQLiteAdapter._write_lock.
|
|
12
|
+
#
|
|
13
|
+
# DB-contract B (v3.13.37): get_next_id's atomic sequence-table increment
|
|
14
|
+
# serialises the ENTIRE ensure-table + seed + increment op under this lock
|
|
15
|
+
# so concurrent callers can never read the same counter and return a
|
|
16
|
+
# duplicate id. Class-level (one per process) so every Database instance /
|
|
17
|
+
# pooled connection contends on the same lock for the same SQLite file.
|
|
18
|
+
@write_lock = Mutex.new
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :write_lock
|
|
21
|
+
end
|
|
22
|
+
|
|
11
23
|
def connect(connection_string, username: nil, password: nil)
|
|
12
24
|
require "sqlite3"
|
|
13
25
|
db_path = self.class.resolve_path(connection_string)
|
|
@@ -87,12 +99,23 @@ module Tina4
|
|
|
87
99
|
@connection.execute("BEGIN TRANSACTION")
|
|
88
100
|
end
|
|
89
101
|
|
|
102
|
+
# Committing/rolling back when no transaction is open is a harmless no-op,
|
|
103
|
+
# NOT a failure — SQLite raises "cannot commit - no transaction is active"
|
|
104
|
+
# in that case. Swallow ONLY that specific condition so a stray commit
|
|
105
|
+
# (e.g. after an autocommit standalone write) doesn't poison the
|
|
106
|
+
# Database-level @last_error. A genuine commit/rollback failure (disk I/O,
|
|
107
|
+
# constraint deferral, locked DB) still propagates so Database#commit can
|
|
108
|
+
# FAIL LOUD per the DB-contract.
|
|
90
109
|
def commit
|
|
91
110
|
@connection.execute("COMMIT")
|
|
111
|
+
rescue SQLite3::SQLException => e
|
|
112
|
+
raise unless e.message.to_s.downcase.include?("no transaction is active")
|
|
92
113
|
end
|
|
93
114
|
|
|
94
115
|
def rollback
|
|
95
116
|
@connection.execute("ROLLBACK")
|
|
117
|
+
rescue SQLite3::SQLException => e
|
|
118
|
+
raise unless e.message.to_s.downcase.include?("no transaction is active")
|
|
96
119
|
end
|
|
97
120
|
|
|
98
121
|
# v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|