tina4ruby 3.13.97 → 3.13.99
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/CHANGELOG.md +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "port_takeover"
|
|
5
6
|
|
|
6
7
|
module Tina4
|
|
7
8
|
class CLI
|
|
@@ -361,77 +362,35 @@ module Tina4
|
|
|
361
362
|
# `tina4 serve` may still hold it. Inside a container the server IS the
|
|
362
363
|
# container, so there is no stale sibling to reclaim from -- and trying is
|
|
363
364
|
# actively dangerous (see #kill_process_on_port).
|
|
365
|
+
# Container detection lives in the shared takeover module now; kept as an
|
|
366
|
+
# instance method so existing callers/specs resolve it on the CLI.
|
|
364
367
|
def in_container?
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
blob = File.read("/proc/1/cgroup")
|
|
368
|
-
blob.include?("docker") || blob.include?("containerd") || blob.include?("kubepods")
|
|
369
|
-
rescue SystemCallError
|
|
370
|
-
false
|
|
368
|
+
Tina4::PortTakeover.in_container?
|
|
371
369
|
end
|
|
372
370
|
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
# Every PID is validated before use. `pid.to_i` on a non-numeric field
|
|
376
|
-
# yields 0, and Process.kill("TERM", 0) signals EVERY process in the
|
|
377
|
-
# caller's own process group -- the server kills itself. That is exactly
|
|
378
|
-
# what happened in a container: "Killed existing process on port 7148
|
|
379
|
-
# (PID: 1 ...)" followed by exit 143.
|
|
380
|
-
#
|
|
381
|
-
# So: skip entirely in a container, accept only all-digit PIDs, and never
|
|
382
|
-
# signal 0 (our process group), 1 (init), or ourselves.
|
|
383
|
-
# The PIDs from `lsof -ti` output that are safe to signal.
|
|
384
|
-
#
|
|
385
|
-
# Pure so the safety rule can be tested directly. An unvalidated parse is a
|
|
386
|
-
# footgun with real teeth: where lsof prints a different shape than -ti
|
|
387
|
-
# implies, a non-numeric field becomes 0, and signalling PID 0 hits EVERY
|
|
388
|
-
# process in the caller's own process group -- the server kills itself.
|
|
389
|
-
#
|
|
390
|
-
# Accept only all-digit tokens; never PID 0 (our group), PID 1 (init),
|
|
391
|
-
# ourselves, or our own process group.
|
|
371
|
+
# Thin wrapper over the shared Tina4::PortTakeover.selectable_pids so the CLI
|
|
372
|
+
# and the runtime bind-failure path share ONE implementation.
|
|
392
373
|
def selectable_pids(lsof_output, me, my_group = nil)
|
|
393
|
-
|
|
394
|
-
lsof_output.split(/\s+/).each do |token|
|
|
395
|
-
next unless token.match?(/\A\d+\z/) # never coerce junk into a PID
|
|
396
|
-
|
|
397
|
-
pid = token.to_i
|
|
398
|
-
next if pid <= 1 || pid == me # 0 = our group, 1 = init, me = suicide
|
|
399
|
-
next if !my_group.nil? && pid == my_group
|
|
400
|
-
|
|
401
|
-
pids << pid unless pids.include?(pid)
|
|
402
|
-
end
|
|
403
|
-
pids
|
|
374
|
+
Tina4::PortTakeover.selectable_pids(lsof_output, me, my_group)
|
|
404
375
|
end
|
|
405
376
|
|
|
377
|
+
# Reclaim `port` from a stale Tina4 dev server, only when it is safe.
|
|
378
|
+
#
|
|
379
|
+
# Routes through the shared identity-checked takeover (TAKEOVER-DEC-01/02): a
|
|
380
|
+
# holder is signalled ONLY when a Tina4 dev server recorded its PID in the
|
|
381
|
+
# per-port PID file. A foreign holder is left running and a clear message is
|
|
382
|
+
# printed; takeover is also skipped in a container, outside dev mode, and when
|
|
383
|
+
# opted out (TINA4_NO_TAKEOVER / --no-kill). Returns true only when a Tina4
|
|
384
|
+
# holder was actually signalled.
|
|
406
385
|
def kill_process_on_port(port)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
my_group = begin
|
|
414
|
-
Process.getpgrp
|
|
415
|
-
rescue StandardError
|
|
416
|
-
nil
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
killed = []
|
|
420
|
-
selectable_pids(result, me, my_group).each do |pid|
|
|
421
|
-
begin
|
|
422
|
-
Process.kill("TERM", pid)
|
|
423
|
-
killed << pid.to_s
|
|
424
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
425
|
-
# Process already gone or no permission
|
|
426
|
-
end
|
|
386
|
+
result = Tina4::PortTakeover.take_over_port(
|
|
387
|
+
port, dev: Tina4::PortTakeover.dev?, no_takeover: Tina4::PortTakeover.no_takeover_opted_out?
|
|
388
|
+
)
|
|
389
|
+
if result.reclaimed?
|
|
390
|
+
puts " #{result.message}"
|
|
391
|
+
return true
|
|
427
392
|
end
|
|
428
|
-
|
|
429
|
-
return false if killed.empty?
|
|
430
|
-
|
|
431
|
-
sleep 0.5
|
|
432
|
-
puts " Killed existing process on port #{port} (PID: #{killed.join(', ')})"
|
|
433
|
-
true
|
|
434
|
-
rescue Errno::ENOENT
|
|
393
|
+
puts " #{result.message}" if result.refused? && !result.message.empty?
|
|
435
394
|
false
|
|
436
395
|
end
|
|
437
396
|
|
|
@@ -485,6 +444,7 @@ module Tina4
|
|
|
485
444
|
opts.on("--production", "Use production server (Puma)") { options[:production] = true }
|
|
486
445
|
opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
|
|
487
446
|
opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
|
|
447
|
+
opts.on("--no-kill", "Never take over the port from a stale dev server") { options[:no_kill] = true }
|
|
488
448
|
end
|
|
489
449
|
parser.parse!(argv)
|
|
490
450
|
|
|
@@ -499,6 +459,11 @@ module Tina4
|
|
|
499
459
|
ENV["TINA4_NO_RELOAD"] = "true"
|
|
500
460
|
end
|
|
501
461
|
|
|
462
|
+
# --no-kill opts out of port takeover for the whole process, so the CLI
|
|
463
|
+
# path here AND the runtime bind-failure fallback both honour it
|
|
464
|
+
# (TAKEOVER-DEC-03).
|
|
465
|
+
ENV["TINA4_NO_TAKEOVER"] = "true" if options[:no_kill]
|
|
466
|
+
|
|
502
467
|
# Priority: CLI flag > ENV var > default
|
|
503
468
|
options[:port] = resolve_config(:port, options[:port])
|
|
504
469
|
options[:host] = resolve_config(:host, options[:host])
|
|
@@ -1337,8 +1302,8 @@ module Tina4
|
|
|
1337
1302
|
|
|
1338
1303
|
# List all #{route_path} with pagination — public read (GET is ungated).
|
|
1339
1304
|
Tina4::Router.get "/api/#{route_path}" do |request, response|
|
|
1340
|
-
page = (request.
|
|
1341
|
-
per_page = (request.
|
|
1305
|
+
page = (request.query["page"] || 1).to_i
|
|
1306
|
+
per_page = (request.query["per_page"] || 20).to_i
|
|
1342
1307
|
offset = (page - 1) * per_page
|
|
1343
1308
|
records = #{model}.all(limit: per_page, offset: offset)
|
|
1344
1309
|
response.json({ data: records.map(&:to_h), count: #{model}.count, page: page, per_page: per_page })
|
|
@@ -3061,17 +3026,40 @@ module Tina4
|
|
|
3061
3026
|
DEFAULT_PORT = 7147
|
|
3062
3027
|
DEFAULT_HOST = "0.0.0.0"
|
|
3063
3028
|
|
|
3064
|
-
# Priority: CLI flag >
|
|
3029
|
+
# Priority: CLI flag > TINA4_PORT > PORT (deprecated) > default.
|
|
3030
|
+
#
|
|
3031
|
+
# This used to read bare PORT only, ignoring TINA4_PORT entirely, while
|
|
3032
|
+
# Tina4.resolve_bind_port (which WebServer/Tina4.run! use) and
|
|
3033
|
+
# Tina4.mcp_port (the +2000 supervisor port) both already read
|
|
3034
|
+
# TINA4_PORT first. So under `tina4ruby serve` with only TINA4_PORT set,
|
|
3035
|
+
# this method alone fell through to bare PORT/DEFAULT_PORT while the
|
|
3036
|
+
# AI port (base+1000, derived from THIS resolved base) and the supervisor
|
|
3037
|
+
# port derived from a DIFFERENT base -- three ports, two answers for the
|
|
3038
|
+
# same knob (DUALPORT-DEC-02, DUALPORT-BASE-PRECEDENCE). Not delegated to
|
|
3039
|
+
# Tina4.resolve_bind_port itself: this runs before `require_relative
|
|
3040
|
+
# "../tina4"` below, so the Tina4 module is not loaded yet at this point.
|
|
3065
3041
|
def resolve_config(key, cli_value)
|
|
3066
3042
|
case key
|
|
3067
3043
|
when :port
|
|
3068
3044
|
return cli_value if cli_value
|
|
3045
|
+
tina4_port = ENV["TINA4_PORT"]
|
|
3046
|
+
return tina4_port.to_i if tina4_port && tina4_port.match?(/\A\d+\z/)
|
|
3069
3047
|
return ENV["PORT"].to_i if ENV["PORT"] && !ENV["PORT"].empty?
|
|
3070
3048
|
DEFAULT_PORT
|
|
3071
3049
|
when :host
|
|
3072
3050
|
return cli_value if cli_value
|
|
3051
|
+
# Host: CLI flag > TINA4_HOST > HOST > default. TINA4_HOST wins over the
|
|
3052
|
+
# legacy plain HOST so a stray OS-level HOST (shared CI runners) can't
|
|
3053
|
+
# silently override the framework's bind. Parity with Python's
|
|
3054
|
+
# resolve_config.
|
|
3055
|
+
return ENV["TINA4_HOST"] if ENV["TINA4_HOST"] && !ENV["TINA4_HOST"].empty?
|
|
3073
3056
|
return ENV["HOST"] if ENV["HOST"] && !ENV["HOST"].empty?
|
|
3074
|
-
|
|
3057
|
+
# DEVADMIN-DEC-02 (feature 127): in dev/serve mode the dashboard exposes
|
|
3058
|
+
# an unauthenticated file/SQL/RCE surface, so the DEFAULT bind is
|
|
3059
|
+
# loopback, not 0.0.0.0. Only the DEFAULT changes: production
|
|
3060
|
+
# (TINA4_DEBUG off) keeps 0.0.0.0, and a developer who WANTS network
|
|
3061
|
+
# exposure sets TINA4_HOST=0.0.0.0 to override deliberately.
|
|
3062
|
+
Tina4.truthy?(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
|
|
3075
3063
|
end
|
|
3076
3064
|
end
|
|
3077
3065
|
|
data/lib/tina4/database.rb
CHANGED
|
@@ -745,7 +745,12 @@ module Tina4
|
|
|
745
745
|
@pk_cache ||= {}
|
|
746
746
|
unless @pk_cache.key?(table)
|
|
747
747
|
@pk_cache[table] = begin
|
|
748
|
-
columns(table).select { |c| c[:primary_key] }
|
|
748
|
+
pk_columns = columns(table).select { |c| c[:primary_key] }
|
|
749
|
+
# ADR-0044 amendment: sort by primary_key_position so a composite
|
|
750
|
+
# PRIMARY KEY (b, a) returns ["b", "a"] (declared key order), not
|
|
751
|
+
# table-column order. A column with no reported position sorts last.
|
|
752
|
+
pk_columns.sort_by! { |c| [c[:primary_key_position].nil? ? 1 : 0, c[:primary_key_position] || 0] }
|
|
753
|
+
pk_columns.map { |c| c[:name].to_s }
|
|
749
754
|
rescue StandardError
|
|
750
755
|
[]
|
|
751
756
|
end
|
|
@@ -958,37 +963,39 @@ module Tina4
|
|
|
958
963
|
# and let the outer transaction commit them.
|
|
959
964
|
def execute_many(sql, params_list = [])
|
|
960
965
|
params_list ||= []
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
966
|
+
# ADR-0044 (DBA-B01): empty input is a successful no-op - it opens no
|
|
967
|
+
# transaction, calls no driver, and performs no write.
|
|
968
|
+
if params_list.empty?
|
|
969
|
+
return Tina4::DatabaseResult.new([], affected_rows: 0, last_id: nil, db: self)
|
|
970
|
+
end
|
|
964
971
|
|
|
972
|
+
# ADR-0044 (DBA-D02): the facade delegates to the driver's OWN
|
|
973
|
+
# execute_many exactly ONCE - it does not loop execute() itself. Native
|
|
974
|
+
# batching (one multi-row VALUES round-trip instead of one per row: 500
|
|
975
|
+
# rows measured 9848ms on PostgreSQL row-at-a-time against 15.8ms
|
|
976
|
+
# batched - 625x, MySQL 216x, MSSQL 121x) is the DRIVER's job.
|
|
977
|
+
#
|
|
978
|
+
# start_transaction/commit/rollback already correctly nest (depth
|
|
979
|
+
# counter) when called from inside an existing explicit transaction, so
|
|
980
|
+
# this brackets exactly the way a standalone start_transaction +
|
|
981
|
+
# execute + commit sequence would.
|
|
982
|
+
start_transaction
|
|
983
|
+
drv = current_driver # now returns the just-pinned driver
|
|
965
984
|
begin
|
|
966
|
-
drv.
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
# array for anything it cannot collapse safely - RETURNING, upserts,
|
|
973
|
-
# non-INSERT statements, ragged rows, Firebird - and the row-at-a-time
|
|
974
|
-
# loop then runs unchanged.
|
|
975
|
-
batched = SQLTranslator.build_batch_inserts(sql, params_list, get_database_type)
|
|
976
|
-
if batched.empty?
|
|
977
|
-
params_list.each { |params| drv.execute(sql, params) }
|
|
978
|
-
else
|
|
979
|
-
batched.each { |chunk_sql, chunk_params| drv.execute(chunk_sql, chunk_params) }
|
|
980
|
-
end
|
|
981
|
-
drv.commit unless already_pinned
|
|
982
|
-
rescue => e
|
|
983
|
-
drv.rollback unless already_pinned
|
|
984
|
-
@last_error = e.message
|
|
985
|
-
raise e
|
|
986
|
-
end
|
|
987
|
-
ensure
|
|
988
|
-
Thread.current[@tx_pin_key] = nil unless already_pinned
|
|
985
|
+
raw = drv.execute_many(sql, params_list)
|
|
986
|
+
commit
|
|
987
|
+
rescue => e
|
|
988
|
+
rollback
|
|
989
|
+
@last_error = e.message
|
|
990
|
+
raise e
|
|
989
991
|
end
|
|
990
992
|
|
|
991
|
-
|
|
993
|
+
# Normalise whatever the driver returned (a Hash {affected_rows:,
|
|
994
|
+
# last_id:} today) into the shared aggregate result. affected_rows is
|
|
995
|
+
# the total ROW count - chunking/native batching must stay invisible.
|
|
996
|
+
affected = raw.is_a?(Hash) ? raw[:affected_rows] : params_list.length
|
|
997
|
+
last_id = raw.is_a?(Hash) ? raw[:last_id] : nil
|
|
998
|
+
last_id ||= begin
|
|
992
999
|
drv.last_insert_id
|
|
993
1000
|
rescue StandardError
|
|
994
1001
|
nil
|
|
@@ -1001,7 +1008,7 @@ module Tina4
|
|
|
1001
1008
|
# double-apply.
|
|
1002
1009
|
Tina4::DatabaseResult.new(
|
|
1003
1010
|
[],
|
|
1004
|
-
affected_rows:
|
|
1011
|
+
affected_rows: affected,
|
|
1005
1012
|
last_id: last_id,
|
|
1006
1013
|
db: self
|
|
1007
1014
|
)
|
|
@@ -1177,6 +1184,15 @@ module Tina4
|
|
|
1177
1184
|
def get_next_id(table, pk_column: "id", generator_name: nil)
|
|
1178
1185
|
drv = current_driver
|
|
1179
1186
|
|
|
1187
|
+
# MongoDB — a DEDICATED atomic counter (findOneAndUpdate($inc) keyed by
|
|
1188
|
+
# _id), monotonic and concurrency-safe. NEVER routed through the relational
|
|
1189
|
+
# tina4_sequences path, whose arithmetic '+ 1' UPDATE has no MongoDB
|
|
1190
|
+
# translation (the increment would be silently dropped and every call
|
|
1191
|
+
# return the same id — a duplicate-key generator).
|
|
1192
|
+
if @driver_name == "mongodb"
|
|
1193
|
+
return drv.get_next_id(table, pk_column)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1180
1196
|
# Firebird — use generators
|
|
1181
1197
|
if @driver_name == "firebird"
|
|
1182
1198
|
gen_name = generator_name || "GEN_#{table.upcase}_ID"
|
|
@@ -1197,27 +1213,41 @@ module Tina4
|
|
|
1197
1213
|
# PostgreSQL — try sequence first, auto-create if missing
|
|
1198
1214
|
if @driver_name == "postgres"
|
|
1199
1215
|
seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
|
|
1216
|
+
# Fast path: the sequence already exists — nextval() is atomic.
|
|
1200
1217
|
begin
|
|
1201
1218
|
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
1202
1219
|
row = rows.is_a?(Array) ? rows.first : nil
|
|
1203
1220
|
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
1204
1221
|
return val.to_i if val
|
|
1205
1222
|
rescue
|
|
1206
|
-
# Sequence
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1223
|
+
# Sequence missing — create it idempotently below.
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
# First use: create the sequence IDEMPOTENTLY (CREATE SEQUENCE IF NOT
|
|
1227
|
+
# EXISTS), seeded from MAX(pk). Two concurrent first-callers therefore
|
|
1228
|
+
# share ONE counter — the loser's create is a no-op, not an error, so it
|
|
1229
|
+
# never falls to the tina4_sequences table and draws a DUPLICATE id from
|
|
1230
|
+
# a second, independent counter (the first-use race).
|
|
1231
|
+
begin
|
|
1232
|
+
max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
|
|
1233
|
+
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
1234
|
+
max_val = row_value(max_row, :max_id)
|
|
1235
|
+
start_val = max_val ? max_val.to_i + 1 : 1
|
|
1236
|
+
drv.execute("CREATE SEQUENCE IF NOT EXISTS #{seq_name} START WITH #{start_val}")
|
|
1237
|
+
drv.commit rescue nil
|
|
1238
|
+
rescue
|
|
1239
|
+
# A concurrent creator won the catalog race — the sequence exists now.
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
# ALWAYS draw from the sequence now that it exists. Never fall to the
|
|
1243
|
+
# sequence table just because our own CREATE lost the race.
|
|
1244
|
+
begin
|
|
1245
|
+
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
1246
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1247
|
+
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
1248
|
+
return val.to_i if val
|
|
1249
|
+
rescue
|
|
1250
|
+
# Truly cannot use a sequence — last-resort table below.
|
|
1221
1251
|
end
|
|
1222
1252
|
end
|
|
1223
1253
|
|
|
@@ -1293,9 +1323,14 @@ module Tina4
|
|
|
1293
1323
|
# error is captured on @last_error and re-raised. Returns the first row (a
|
|
1294
1324
|
# Hash) or nil on a SUCCESSFUL "no row" read.
|
|
1295
1325
|
def fetch_one_direct(sql, params)
|
|
1296
|
-
|
|
1326
|
+
# ADR-0044 (DBA-D03): delegates to the adapter's OWN fetch_one exactly
|
|
1327
|
+
# once - no pagination count probe (the old path ran the full paginated
|
|
1328
|
+
# #fetch, which the driver builds via a COUNT(*) subquery + LIMIT/OFFSET
|
|
1329
|
+
# SQL, just to keep the first row).
|
|
1330
|
+
drv = current_driver
|
|
1331
|
+
result = drv.fetch_one(sql, params)
|
|
1297
1332
|
@last_error = nil
|
|
1298
|
-
result
|
|
1333
|
+
result
|
|
1299
1334
|
rescue => e
|
|
1300
1335
|
@last_error = driver_error_message(current_driver, e)
|
|
1301
1336
|
raise
|
|
@@ -1498,9 +1533,16 @@ module Tina4
|
|
|
1498
1533
|
# Row likely already exists (PK conflict) — fine, keep going.
|
|
1499
1534
|
drv.rollback rescue nil
|
|
1500
1535
|
end
|
|
1501
|
-
|
|
1536
|
+
# Single ATOMIC increment-and-return. The old path did the UPDATE then a
|
|
1537
|
+
# SEPARATE SELECT: between them another caller could increment and commit,
|
|
1538
|
+
# so both read the same value and returned a DUPLICATE id (a TOCTOU).
|
|
1539
|
+
# PostgreSQL (the engine that reaches this fallback) supports
|
|
1540
|
+
# UPDATE ... RETURNING, so the value read is exactly the one just written.
|
|
1541
|
+
rows = drv.execute_query(
|
|
1542
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ? RETURNING current_value",
|
|
1543
|
+
[seq_name]
|
|
1544
|
+
)
|
|
1502
1545
|
drv.commit rescue nil
|
|
1503
|
-
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
1504
1546
|
row = rows.is_a?(Array) ? rows.first : nil
|
|
1505
1547
|
val = row_value(row, :current_value)
|
|
1506
1548
|
raise "get_next_id: sequence row '#{seq_name}' missing" if val.nil?
|
|
@@ -1765,7 +1807,13 @@ module Tina4
|
|
|
1765
1807
|
klass_name = DRIVERS[@driver_name]
|
|
1766
1808
|
raise "Unknown database driver: #{@driver_name}" unless klass_name
|
|
1767
1809
|
klass = Object.const_get(klass_name)
|
|
1768
|
-
klass.new
|
|
1810
|
+
driver = klass.new
|
|
1811
|
+
# ADR-0044 / DBA-S02: fail loud, at registration, when the driver does
|
|
1812
|
+
# not implement every required adapter capability - instead of failing
|
|
1813
|
+
# later with a bare NoMethodError on whichever call path touches the
|
|
1814
|
+
# gap first.
|
|
1815
|
+
Tina4::DatabaseAdapter.validate!(driver, @driver_name)
|
|
1816
|
+
driver
|
|
1769
1817
|
rescue NameError
|
|
1770
1818
|
raise "Driver #{klass_name} not loaded. Install the required gem."
|
|
1771
1819
|
end
|
|
@@ -39,27 +39,48 @@ module Tina4
|
|
|
39
39
|
# distinction is the whole point: including this module makes every driver
|
|
40
40
|
# respond to everything, so +respond_to?+ stopped being able to tell the
|
|
41
41
|
# difference.
|
|
42
|
+
# ADR-0044 / DBA-S02: a registered adapter does not satisfy the Tina4
|
|
43
|
+
# database adapter contract. Raised at registration time, naming the
|
|
44
|
+
# adapter and the missing capability.
|
|
45
|
+
class AdapterContractError < StandardError; end
|
|
46
|
+
|
|
47
|
+
# ADR-0044 / DBA-P02: a provider/deployment cannot guarantee an atomic
|
|
48
|
+
# multi-row batch. Raised by the shared execute_many default (or a driver's
|
|
49
|
+
# own override) before any row is written.
|
|
50
|
+
class UnsupportedAtomicBatchError < StandardError; end
|
|
51
|
+
|
|
52
|
+
# ADR-0044 / DBA-B05: a ragged/mismatched parameter set in a batch. Raised
|
|
53
|
+
# before any row of the batch is written.
|
|
54
|
+
class BindingCountMismatchError < StandardError; end
|
|
55
|
+
|
|
42
56
|
module DatabaseAdapter
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
57
|
+
# The exact fourteen capabilities every driver must provide (ADR-0044,
|
|
58
|
+
# plan/v3/fixtures/adapter_contract.json). None is optional. Kept as data
|
|
59
|
+
# so the conformance spec can read it instead of maintaining a second copy.
|
|
46
60
|
#
|
|
47
|
-
# CRUD (insert/update/delete)
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
61
|
+
# CRUD (insert/update/delete) and DDL (create_table/add_column) are NOT
|
|
62
|
+
# here - they are composable above the adapter from execute +
|
|
63
|
+
# get_database_type, and Ruby was already doing exactly that in the
|
|
64
|
+
# facade, which is why Ruby's driver layer is 1335 LOC against PHP's 5823
|
|
65
|
+
# for the same job. executeMany and fetchOne, by contrast, ARE required
|
|
66
|
+
# adapter primitives under ADR-0044 (superseding the original redesign
|
|
67
|
+
# that placed them above the adapter) - see execute_many/fetch_one below.
|
|
54
68
|
CONTRACT = %i[
|
|
55
|
-
|
|
56
|
-
execute fetch
|
|
69
|
+
connect close get_database_type
|
|
70
|
+
execute execute_many fetch fetch_one
|
|
57
71
|
start_transaction commit rollback autocommit
|
|
58
|
-
|
|
59
|
-
last_insert_id error
|
|
72
|
+
tables columns table_exists?
|
|
60
73
|
].freeze
|
|
61
74
|
|
|
62
|
-
|
|
75
|
+
# The subset with NO usable generic default - a driver MUST override
|
|
76
|
+
# these or the raising stub is inherited verbatim.
|
|
77
|
+
ABSTRACT_CONTRACT = %i[
|
|
78
|
+
connect close get_database_type
|
|
79
|
+
execute commit rollback
|
|
80
|
+
tables columns
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
ABSTRACT_CONTRACT.each do |name|
|
|
63
84
|
define_method(name) do |*_args, **_kwargs, &_block|
|
|
64
85
|
raise NotImplementedError,
|
|
65
86
|
"#{self.class} does not implement ##{name}, which the Tina4 " \
|
|
@@ -67,6 +88,139 @@ module Tina4
|
|
|
67
88
|
end
|
|
68
89
|
end
|
|
69
90
|
|
|
91
|
+
# `open` is the pre-3.14 spelling every Ruby driver's constructor already
|
|
92
|
+
# calls; `connect` is the ADR-0044 canonical lifecycle name. A temporary
|
|
93
|
+
# forwarding alias, to be removed or explicitly deprecated before 3.14.
|
|
94
|
+
def open(*args, **kwargs)
|
|
95
|
+
connect(*args, **kwargs)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `begin_transaction` is every existing driver's real spelling;
|
|
99
|
+
# `start_transaction` is the ADR-0044 canonical name (matches Python/PHP/
|
|
100
|
+
# Node). A thin forwarding default, exactly like `open` above.
|
|
101
|
+
def start_transaction(*args, **kwargs)
|
|
102
|
+
begin_transaction(*args, **kwargs)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# -- Capabilities with a REAL, usable generic default -------------------
|
|
106
|
+
# Every driver already implements execute_query/tables (required above)
|
|
107
|
+
# with identical spelling, so these compose safely for any driver that
|
|
108
|
+
# does not provide its own optimized override.
|
|
109
|
+
|
|
110
|
+
# ADR-0044: the adapter-level read-many primitive. Returns a native list
|
|
111
|
+
# of records with NO pagination envelope and NO count probe - the
|
|
112
|
+
# facade (Database#fetch) owns pagination and the true-total count.
|
|
113
|
+
def fetch(sql, params = [])
|
|
114
|
+
execute_query(sql, params)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ADR-0044: one native record or nil. No pagination count probe.
|
|
118
|
+
def fetch_one(sql, params = [])
|
|
119
|
+
fetch(sql, params).first
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ADR-0044: table_exists? is required on the adapter. Drivers that expose
|
|
123
|
+
# a native, efficient check (SQLite, MySQL, MSSQL) override this; the rest
|
|
124
|
+
# get a correct, if less efficient, default from the required `tables`.
|
|
125
|
+
def table_exists?(name)
|
|
126
|
+
tables.any? { |t| t.to_s.downcase == name.to_s.downcase }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ADR-0044: readable and writable native boolean, defaulting true.
|
|
130
|
+
def autocommit
|
|
131
|
+
@tina4_autocommit.nil? ? true : @tina4_autocommit
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def autocommit=(value)
|
|
135
|
+
@tina4_autocommit = value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ADR-0044 / DBA-P02: whether this adapter's deployment can guarantee an
|
|
139
|
+
# atomic multi-row batch write. Every built-in driver defaults to true; a
|
|
140
|
+
# deployment that genuinely cannot (a standalone MongoDB without a
|
|
141
|
+
# replica set is the motivating real case) sets this false so
|
|
142
|
+
# execute_many rejects BEFORE the first write.
|
|
143
|
+
def supports_atomic_batch
|
|
144
|
+
@tina4_supports_atomic_batch.nil? ? true : @tina4_supports_atomic_batch
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def supports_atomic_batch=(value)
|
|
148
|
+
@tina4_supports_atomic_batch = value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ADR-0044: one aggregate result for the whole batch - {affected_rows:,
|
|
152
|
+
# last_id:}. Transaction OWNERSHIP is deliberately NOT here: the facade
|
|
153
|
+
# (Database#execute_many) brackets begin/commit/rollback around exactly
|
|
154
|
+
# one call to this method, exactly as it already does for a standalone
|
|
155
|
+
# start_transaction()/execute()/commit() sequence, so a driver that
|
|
156
|
+
# overrides this for native batching never has to duplicate that policy.
|
|
157
|
+
def execute_many(sql, params_list = [])
|
|
158
|
+
rows = params_list || []
|
|
159
|
+
return { affected_rows: 0, last_id: nil } if rows.empty?
|
|
160
|
+
|
|
161
|
+
# ADR-0044 (DBA-B05): a ragged parameter set must fail BEFORE any
|
|
162
|
+
# durable partial success. Checked generically (every row's length must
|
|
163
|
+
# match the first) rather than parsing the SQL's own placeholder count,
|
|
164
|
+
# so it holds for every driver without a dialect-specific parser.
|
|
165
|
+
expected = rows.first.length
|
|
166
|
+
rows.each do |params|
|
|
167
|
+
next if params.length == expected
|
|
168
|
+
|
|
169
|
+
raise Tina4::BindingCountMismatchError,
|
|
170
|
+
"execute_many binding count mismatch - expected #{expected} " \
|
|
171
|
+
"parameters, got #{params.length}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if !supports_atomic_batch && rows.length > 1
|
|
175
|
+
raise Tina4::UnsupportedAtomicBatchError,
|
|
176
|
+
"provider #{get_database_type.inspect} cannot guarantee an atomic " \
|
|
177
|
+
"batch write on this deployment (required deployment capability: " \
|
|
178
|
+
"a transaction-capable configuration) - rejected before the first " \
|
|
179
|
+
"write rather than risking partial durability"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ONE round-trip per CHUNK instead of one per ROW - see
|
|
183
|
+
# Tina4::SQLTranslator.build_batch_inserts for the measured 121x-625x.
|
|
184
|
+
batched = Tina4::SQLTranslator.build_batch_inserts(sql, rows, get_database_type)
|
|
185
|
+
if batched.empty?
|
|
186
|
+
rows.each { |params| execute(sql, params) }
|
|
187
|
+
else
|
|
188
|
+
batched.each { |chunk_sql, chunk_params| execute(chunk_sql, chunk_params) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
last_id = begin
|
|
192
|
+
last_insert_id
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
{ affected_rows: rows.length, last_id: last_id }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Fail loud when a class does not declare every required capability.
|
|
200
|
+
#
|
|
201
|
+
# For ABSTRACT_CONTRACT this checks the method was actually OVERRIDDEN
|
|
202
|
+
# (implemented_by?, below) rather than merely inherited as the raising
|
|
203
|
+
# stub. For the capabilities with a real generic default (execute_many,
|
|
204
|
+
# fetch, fetch_one, table_exists?, autocommit, supports_atomic_batch),
|
|
205
|
+
# simple presence is sufficient - the module's own default IS a complete,
|
|
206
|
+
# correct implementation.
|
|
207
|
+
def self.validate!(adapter_object, name = nil)
|
|
208
|
+
label = name || adapter_object.class.name
|
|
209
|
+
missing = CONTRACT.select do |capability|
|
|
210
|
+
if ABSTRACT_CONTRACT.include?(capability)
|
|
211
|
+
!implemented_by?(adapter_object, capability)
|
|
212
|
+
else
|
|
213
|
+
!adapter_object.respond_to?(capability)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
return if missing.empty?
|
|
217
|
+
|
|
218
|
+
raise Tina4::AdapterContractError,
|
|
219
|
+
"adapter #{label.inspect} does not implement the required Tina4 " \
|
|
220
|
+
"database adapter contract capabilities: #{missing.join(', ')} " \
|
|
221
|
+
"(ADR-0044 / plan/v3/fixtures/adapter_contract.json)"
|
|
222
|
+
end
|
|
223
|
+
|
|
70
224
|
# == Bounding the connect
|
|
71
225
|
#
|
|
72
226
|
# A connect that can block forever hangs the whole application with NO log,
|