tina4ruby 3.13.98 → 3.13.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +277 -27
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -2
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
- return true if File.exist?("/.dockerenv") || File.exist?("/run/.containerenv")
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
- # Kill any process listening on `port`. Returns true if anything was killed.
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
- pids = []
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
- return false if in_container?
408
-
409
- result = `lsof -ti :#{port} 2>/dev/null`.strip
410
- return false if result.empty?
411
-
412
- me = Process.pid
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.params["page"] || 1).to_i
1341
- per_page = (request.params["per_page"] || 20).to_i
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 > ENV var > default
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
- DEFAULT_HOST
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
 
@@ -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] }.map { |c| c[:name].to_s }
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
- already_pinned = !Thread.current[@tx_pin_key].nil?
962
- drv = current_driver
963
- Thread.current[@tx_pin_key] = drv unless already_pinned
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.begin_transaction unless already_pinned
967
- begin
968
- # ONE round-trip per CHUNK instead of one per ROW. Looping execute()
969
- # here pays a full network round-trip for every row: 500 rows took
970
- # 9848ms on PostgreSQL against 15.8ms as a single multi-row VALUES
971
- # (625x), MySQL 216x, MSSQL 121x. build_batch_inserts returns an empty
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
- last_id = begin
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: params_list.length,
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 does not exist auto-create it seeded from MAX
1207
- begin
1208
- max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
1209
- max_row = max_rows.is_a?(Array) ? max_rows.first : nil
1210
- max_val = row_value(max_row, :max_id)
1211
- start_val = max_val ? max_val.to_i + 1 : 1
1212
- drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
1213
- drv.commit rescue nil
1214
- rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
1215
- row = rows.is_a?(Array) ? rows.first : nil
1216
- val = row_value(row, :next_id) || row_value(row, :nextval)
1217
- return val&.to_i || start_val
1218
- rescue
1219
- # Fall through to sequence table fallback
1220
- end
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
- result = fetch(sql, params, limit: 1, no_cache: true)
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.first
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
- drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
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
- # Methods a driver MUST override. Kept as data so the conformance spec can
44
- # read it instead of maintaining a second copy of the list.
45
- # The REDESIGNED contract: only what genuinely differs per engine.
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), executeMany, fetchOne and DDL
48
- # (create_table/add_column) are NOT here. They are composable above the
49
- # adapter from execute + fetch + get_database_type, and Ruby was already
50
- # doing exactly that in the facade - which is why Ruby's driver layer is
51
- # 1335 LOC against PHP's 5823 for the same job. The first contract this row
52
- # produced would have made Ruby write those seven more times; this one keeps
53
- # the shape Ruby already had and asks the other three to adopt it.
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
- open close get_database_type
56
- execute fetch
69
+ connect close get_database_type
70
+ execute execute_many fetch fetch_one
57
71
  start_transaction commit rollback autocommit
58
- get_tables get_columns table_exists
59
- last_insert_id error
72
+ tables columns table_exists?
60
73
  ].freeze
61
74
 
62
- CONTRACT.each do |name|
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,