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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -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 +24 -2
  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 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/mcp.rb CHANGED
@@ -590,7 +590,15 @@ module Tina4
590
590
  # share one fully-populated tool registry.
591
591
  def self._default_mcp_server
592
592
  @_default_mcp_server ||= begin
593
- server = McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools")
593
+ # VERSION-DEC-01 (feature 130): the built-in dev server's serverInfo
594
+ # must report the SAME version every other surface does (health,
595
+ # banner, dashboard), not the constructor's generic "1.0.0" default --
596
+ # found by direct verification against this live source, not assumed
597
+ # correct from the "Ruby is the reference" default. A user's OWN custom
598
+ # McpServer.new(path, name: ...) (no version: kwarg) is unaffected --
599
+ # that default stays "1.0.0" for app authors who have not set their own
600
+ # tool-server version.
601
+ server = McpServer.new("/__dev/mcp", name: "Tina4 Dev Tools", version: Tina4::VERSION)
594
602
  McpDevTools.register(server)
595
603
  server
596
604
  end
@@ -193,6 +193,22 @@ module Tina4
193
193
  )
194
194
  end
195
195
 
196
+ # TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): the REAL-SEND path only --
197
+ # capture already returned above, so this never touches the capture
198
+ # branch. When the list is non-empty, replace every recipient with the
199
+ # redirect list (so ONLY the dev list receives the mail, never the real
200
+ # recipients) and preserve the originals in a header. Subject/body/
201
+ # attachments are untouched; #send's return shape is unchanged. Read
202
+ # fresh per call, matching should_capture?'s own env-read style.
203
+ redirect_to = parse_mail_redirect_list(ENV["TINA4_MAIL_REDIRECT_TO"])
204
+ unless redirect_to.empty?
205
+ original_to = (normalize_recipients(to) + normalize_recipients(cc) + normalize_recipients(bcc)).join(", ")
206
+ to = redirect_to
207
+ cc = []
208
+ bcc = []
209
+ headers = (headers || {}).merge("X-Tina4-Original-To" => original_to)
210
+ end
211
+
196
212
  message_id = "<#{SecureRandom.uuid}@#{@host}>"
197
213
  raw = build_message(
198
214
  to: to, subject: subject, body: body, html: html, text: text,
@@ -476,6 +492,15 @@ module Tina4
476
492
  end
477
493
  end
478
494
 
495
+ # Parse TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): comma-separated addresses,
496
+ # each trimmed, blanks dropped. Empty/unset -> [] (redirect off, no
497
+ # behaviour change).
498
+ def parse_mail_redirect_list(raw)
499
+ return [] if raw.nil? || raw.empty?
500
+
501
+ raw.split(",").map(&:strip).reject(&:empty?)
502
+ end
503
+
479
504
  def format_address(address, name = nil)
480
505
  if name && !name.empty?
481
506
  "#{name} <#{address}>"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module Tina4
4
6
  class Middleware
5
7
  class << self
@@ -218,8 +220,8 @@ module Tina4
218
220
  # per-route "filter" middleware (a 2-arg callable returning false, see
219
221
  # Tina4::Route#run_middleware) must obey the SAME row as a before_* hook,
220
222
  # and the rule should exist exactly once.
221
- def refuse(response)
222
- forbid(response) if default_response?(response)
223
+ def refuse(request, response)
224
+ forbid(request, response) if default_response?(response)
223
225
  response
224
226
  end
225
227
 
@@ -263,7 +265,7 @@ module Tina4
263
265
  elsif result.is_a?(Array) && result.length == 2
264
266
  request, response = result
265
267
  elsif result == false
266
- refuse(response)
268
+ refuse(request, response)
267
269
  return [true, request, response]
268
270
  end
269
271
 
@@ -294,16 +296,47 @@ module Tina4
294
296
  body.to_s.empty?
295
297
  end
296
298
 
297
- # The canonical refusal, in the same shape as #middleware_500 so the two
298
- # framework-generated error bodies match byte for byte across all four
299
- # frameworks.
300
- def forbid(response)
299
+ # The canonical refusal (ERR-DEC-01/ERR-DEC-02): routed through the SAME
300
+ # negotiated renderer 404/500 use, so a middleware refusal looks like
301
+ # every other error page - a user template if the app ships one, the
302
+ # framework's 403.twig otherwise, negotiated JSON for an API client -
303
+ # instead of the old bare/un-negotiated `{"error":"Forbidden","status":403}`.
304
+ def forbid(request, response)
305
+ request_id = Tina4::Log.get_request_id || ""
306
+ accept = request.respond_to?(:headers) ? (request.headers["accept"] || "") : ""
307
+
308
+ if Tina4::Template.wants_json?(accept)
309
+ set_response_json(response, error_json_body(request_id))
310
+ return response
311
+ end
312
+
313
+ path = request.respond_to?(:path) ? request.path.to_s : ""
314
+ html = begin
315
+ Tina4::Template.render_error(403, { "path" => CGI.escapeHTML(path), "request_id" => request_id })
316
+ rescue StandardError
317
+ nil
318
+ end
319
+
320
+ if html && response.respond_to?(:html)
321
+ response.html(html, 403)
322
+ else
323
+ set_response_json(response, error_json_body(request_id))
324
+ end
325
+ response
326
+ end
327
+
328
+ # The canonical JSON error envelope (ERR-DEC-02) - the SAME shape
329
+ # Python/PHP/Node build: {error: true, code, message, status, request_id}.
330
+ def error_json_body(request_id)
331
+ { error: true, code: "FORBIDDEN", message: "Forbidden", status: 403, request_id: request_id }
332
+ end
333
+
334
+ def set_response_json(response, body)
301
335
  if response.respond_to?(:json)
302
- response.json({ error: "Forbidden", status: 403 }, 403)
336
+ response.json(body, 403)
303
337
  elsif response.respond_to?(:status_code=)
304
338
  response.status_code = 403
305
339
  end
306
- response
307
340
  end
308
341
 
309
342
  # Copy a response's state onto the object the CALLER still holds.
@@ -567,111 +600,171 @@ module Tina4
567
600
 
568
601
  # CsrfMiddleware -- validates form tokens on state-changing requests.
569
602
  #
570
- # Off by default -- only active when TINA4_CSRF=true in .env or when
571
- # registered explicitly via Router.use(CsrfMiddleware).
603
+ # OFF by default: the middleware is NOT attached unless TINA4_CSRF is truthy
604
+ # (true/1/yes/on -- see .attach_from_env, called once at boot in
605
+ # Tina4.initialize!) OR it is registered explicitly via
606
+ # Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is the
607
+ # kill switch that disables enforcement again.
572
608
  #
573
- # Behaviour:
574
- # - Skips GET, HEAD, OPTIONS requests.
575
- # - Skips routes marked .no_auth.
576
- # - Skips requests with a valid Authorization: Bearer header (API clients).
577
- # - Checks request.body["formToken"] then request.headers["X-Form-Token"].
578
- # - Rejects if token found in request.query["formToken"] (log warning, 403).
579
- # - Validates token with Auth.valid_token using SECRET env var.
580
- # - If token payload has session_id, verifies it matches request.session.session_id.
581
- # - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
609
+ # Behaviour (identical to the Python master's CsrfMiddleware.before_csrf):
610
+ # - Skips GET, HEAD, OPTIONS (safe methods).
611
+ # - Skips a public write route (.no_auth / auth: false -- auth_required
612
+ # false). The matched Route is attached to the request before this
613
+ # post-match middleware runs, so a genuinely public endpoint (login,
614
+ # webhook) is not gated.
615
+ # - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
616
+ # blank (there is NO built-in default), and a blank HMAC key is publicly
617
+ # reproducible -- so no token can be trusted and every write is rejected
618
+ # (403). This is the SEC-01 no-default-secret guarantee.
619
+ # - Skips a request carrying a valid Authorization: Bearer token (API clients).
620
+ # - Reads request.body["formToken"] then the X-Form-Token header.
621
+ # - Rejects a token sent in the query string (403 + a logged warning) -- a
622
+ # URL leaks through logs, referers and history.
623
+ # - Validates the token with Auth.valid_token using the resolved secret, and
624
+ # enforces that the token's "type" claim is "form" -- a non-form JWT in the
625
+ # formToken slot is rejected even when its signature verifies.
626
+ # - If the token carries a session_id, it must match the request session's id.
627
+ # - Every rejection is HTTP 403 with the CSRF_INVALID envelope
628
+ # ({error:true, code:"CSRF_INVALID", message:, status:403}) via
629
+ # response.error -- byte-identical to Python/PHP/Node.
582
630
  class CsrfMiddleware
583
631
  class << self
584
632
  def before_csrf(request, response)
585
- # Allow disabling CSRF via env var
586
- csrf_env = ENV["TINA4_CSRF"].to_s.downcase
633
+ # 1. Kill switch -- TINA4_CSRF in {false,0,no} disables all CSRF checks,
634
+ # even when the middleware is attached explicitly. Unset = enforced.
635
+ csrf_env = ENV["TINA4_CSRF"].to_s.strip.downcase
587
636
  return [request, response] if %w[false 0 no].include?(csrf_env)
588
637
 
589
- # Skip safe HTTP methods
638
+ # 2. Safe HTTP methods never change state -- skip.
590
639
  method = (request.method || "GET").upcase
591
640
  return [request, response] if %w[GET HEAD OPTIONS].include?(method)
592
641
 
593
- # Skip routes marked no_auth
594
- handler = request.respond_to?(:handler) ? request.handler : nil
595
- if handler
596
- no_auth = if handler.is_a?(Hash)
597
- handler[:no_auth] || handler[:noAuth]
598
- elsif handler.respond_to?(:no_auth)
599
- handler.no_auth
600
- end
601
- return [request, response] if no_auth
642
+ # 3. Public write routes (.no_auth / auth: false) skip CSRF -- a
643
+ # genuinely public endpoint has no session to protect. The matched
644
+ # Route is attached to the request before this post-match middleware
645
+ # runs (DispatchPipeline#prepare_route_request); a public write route
646
+ # has auth_required == false -- the SAME signal the auth gate reads.
647
+ # (Reading request.handler, as this once did, was dead code: the live
648
+ # request never carries a handler, so the no_auth skip never fired.)
649
+ route = request.respond_to?(:route) ? request.route : nil
650
+ if route.respond_to?(:auth_required) && route.auth_required == false
651
+ return [request, response]
652
+ end
653
+
654
+ # 4/5. Resolve the signing secret ONCE, fail-closed. TINA4_SECRET unset
655
+ # resolves to blank (there is NO built-in default); a blank HMAC key
656
+ # is publicly reproducible, so a token signed with it -- or with the
657
+ # retired public 'tina4-default-secret' -- is a forgery. Reject every
658
+ # write rather than validate against a guessable key. SEC-01.
659
+ secret = Tina4::Auth.hmac_secret
660
+ if secret.to_s.empty?
661
+ return [request, response.error(
662
+ "CSRF_INVALID",
663
+ "CSRF token cannot be validated: TINA4_SECRET is not set",
664
+ 403
665
+ )]
602
666
  end
603
667
 
604
- # Skip requests with valid Bearer token (API clients)
668
+ # 6. A valid Bearer JWT means an API client authenticating per request --
669
+ # not subject to the cookie-replay attack CSRF defends against.
605
670
  headers = request.respond_to?(:headers) ? request.headers : {}
606
- auth_header = headers["authorization"] || headers["Authorization"] || ""
671
+ auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
607
672
  if auth_header.start_with?("Bearer ")
608
- bearer_token = auth_header[7..].strip
609
- unless bearer_token.empty?
610
- return [request, response] if Tina4::Auth.valid_token(bearer_token)
611
- end
673
+ bearer_token = auth_header[7..].to_s.strip
674
+ return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
612
675
  end
613
676
 
614
- # Reject if token is in query string (security risk)
615
- query = if request.respond_to?(:params)
616
- request.params
617
- elsif request.respond_to?(:query)
618
- request.query
619
- else
620
- {}
621
- end
622
- query ||= {}
623
-
624
- if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
677
+ # 7. A token in the query string leaks through logs/referers/history --
678
+ # reject it. Read the QUERY STRING only, never request.params, which
679
+ # merges the body (a legit body token would false-trip this check).
680
+ query = request.respond_to?(:query) ? request.query : {}
681
+ query = {} unless query.is_a?(Hash)
682
+ if !query["formToken"].to_s.empty?
625
683
  Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
626
- response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
627
- return [request, response]
684
+ return [request, response.error(
685
+ "CSRF_INVALID",
686
+ "Form token must not be sent in the URL query string",
687
+ 403
688
+ )]
628
689
  end
629
690
 
630
- # Extract token: body first, then header
691
+ # 8. Extract the token: body first, then the X-Form-Token header.
631
692
  token = nil
632
693
  body = request.respond_to?(:body) ? request.body : nil
633
- body ||= {}
634
694
  token = body["formToken"] if body.is_a?(Hash)
635
-
636
695
  if token.nil? || token.to_s.empty?
637
- token = headers["X-Form-Token"] || headers["x-form-token"] || ""
696
+ token = headers["X-Form-Token"] || headers["x-form-token"]
638
697
  end
639
698
 
699
+ # 9. Missing token -- reject.
640
700
  if token.nil? || token.to_s.empty?
641
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
642
- return [request, response]
701
+ return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
643
702
  end
644
703
 
645
- # Validate the token
646
- unless Tina4::Auth.valid_token(token.to_s)
647
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
648
- return [request, response]
704
+ # 10. Validate signature + expiry with the resolved secret. valid_token
705
+ # returns the verified payload Hash (or nil) in 3.13.0+.
706
+ payload = Tina4::Auth.valid_token(token.to_s)
707
+ unless payload
708
+ return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
649
709
  end
650
710
 
651
- # Session binding if token has session_id, verify it matches
652
- payload = Tina4::Auth.get_payload(token.to_s) || {}
711
+ # 11. Enforce the form-token TYPE -- a valid signature is not enough. A
712
+ # non-form JWT (e.g. an auth/session token) must never be accepted in
713
+ # the formToken slot.
714
+ payload = {} unless payload.is_a?(Hash)
715
+ if payload["type"] != "form"
716
+ return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
717
+ end
718
+
719
+ # 12. Session binding -- a token minted for one session cannot be replayed
720
+ # against another. Read the request session's OWN id: Tina4::Session
721
+ # exposes get_session_id (NOT session_id -- reading session_id then
722
+ # session.get("session_id") looked up a DATA key, never the id, so
723
+ # binding silently never fired against a real session). A plain Hash
724
+ # session exposes "session_id".
653
725
  token_session_id = payload["session_id"]
654
726
  if token_session_id
655
- current_session_id = nil
656
727
  session = request.respond_to?(:session) ? request.session : nil
657
- if session
658
- current_session_id = if session.respond_to?(:session_id)
659
- session.session_id
660
- elsif session.is_a?(Hash)
661
- session["session_id"]
662
- elsif session.respond_to?(:get)
663
- session.get("session_id")
664
- end
665
- end
728
+ current_session_id =
729
+ if session.nil?
730
+ nil
731
+ elsif session.respond_to?(:session_id)
732
+ session.session_id
733
+ elsif session.respond_to?(:get_session_id)
734
+ session.get_session_id
735
+ elsif session.is_a?(Hash)
736
+ session["session_id"]
737
+ end
666
738
 
667
739
  if current_session_id && token_session_id != current_session_id
668
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
669
- return [request, response]
740
+ return [request, response.error("CSRF_INVALID", "Invalid or missing form token", 403)]
670
741
  end
671
742
  end
672
743
 
744
+ # 13. All checks passed.
673
745
  [request, response]
674
746
  end
747
+
748
+ # Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
749
+ #
750
+ # CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
751
+ # attached, so a default app has no CSRF gate. A truthy value
752
+ # (true/1/yes/on, case-insensitive) attaches it globally so every
753
+ # state-changing route is gated -- the env flag is the switch, no code
754
+ # change needed. Idempotent (Middleware.use de-dupes). Returns true when the
755
+ # middleware is now attached. Mirrors the Python master's
756
+ # attach_csrf_from_env; the framework calls it once at boot
757
+ # (Tina4.initialize!). A false/0/no value still lets an explicit
758
+ # Router.use(CsrfMiddleware) opt-in be disabled at runtime by the kill
759
+ # switch in before_csrf.
760
+ def attach_from_env
761
+ value = ENV["TINA4_CSRF"].to_s.strip.downcase
762
+ if %w[true 1 yes on].include?(value)
763
+ Tina4::Middleware.use(Tina4::CsrfMiddleware)
764
+ return true
765
+ end
766
+ false
767
+ end
675
768
  end
676
769
  end
677
770
 
@@ -684,12 +777,32 @@ module Tina4
684
777
  # TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
685
778
  class SecurityHeadersMiddleware
686
779
  class << self
780
+ # Register this middleware in the default chain (secure-by-default).
781
+ #
782
+ # Unlike CSRF (opt-in via TINA4_CSRF) this is UNCONDITIONAL: a default app
783
+ # ships the security headers with no opt-in -- the SECHDR-DEC-01 posture
784
+ # that closes the SECHDR-OFF-BY-DEFAULT gap (the middleware existed with good
785
+ # defaults but was never registered). Idempotent (Middleware.use de-dupes).
786
+ # The framework calls it once at boot (Tina4.initialize!). Returns true.
787
+ def attach
788
+ Tina4::Middleware.use(self)
789
+ true
790
+ end
791
+
687
792
  def before_security(request, response)
688
793
  response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
689
794
  response.headers["X-Content-Type-Options"] = "nosniff"
690
795
 
796
+ # HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
797
+ # plain-HTTP response is inert at best and ships a bad max-age on an
798
+ # unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
799
+ # request is HTTPS -- Request.secure_scheme? honours x-forwarded-proto
800
+ # (first hop) then rack.url_scheme, the same source of truth the session
801
+ # cookie's Secure flag uses. Defensive env lookup keeps a non-Request from
802
+ # turning every response into a 500 now that this runs on every request.
691
803
  hsts = ENV["TINA4_HSTS"] || ""
692
- unless hsts.empty?
804
+ env = request.respond_to?(:env) ? request.env : {}
805
+ if !hsts.empty? && Tina4::Request.secure_scheme?(env)
693
806
  response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
694
807
  end
695
808
 
@@ -199,14 +199,23 @@ module Tina4
199
199
  end
200
200
  # migration_name is UNIQUE: a migration is "applied" iff a success row
201
201
  # exists, so a re-applied name must never duplicate a tracking row.
202
+ # Firebird's column-definition grammar requires DEFAULT to precede
203
+ # NOT NULL (<col> <type> [DEFAULT <value>] [NOT NULL]) -- unlike
204
+ # PostgreSQL/MySQL/SQLite/MSSQL, which accept either order. The
205
+ # previous "NOT NULL DEFAULT 1" order raised "Invalid token ...
206
+ # DEFAULT" (SQL error -104) on a real server; never caught before
207
+ # because no prior test constructed a real Migration against a real
208
+ # Firebird connection (MIG-FBMSSQL-MOCK, feature 15). Mirrors the
209
+ # Python reference (_create_v3_table's Firebird branch already uses
210
+ # this order).
202
211
  @db.execute(<<~SQL)
203
212
  CREATE TABLE #{TRACKING_TABLE} (
204
213
  id INTEGER NOT NULL PRIMARY KEY,
205
214
  migration_name VARCHAR(500) NOT NULL UNIQUE,
206
215
  description VARCHAR(500),
207
- batch INTEGER NOT NULL DEFAULT 1,
216
+ batch INTEGER DEFAULT 1 NOT NULL,
208
217
  executed_at VARCHAR(50) NOT NULL,
209
- passed INTEGER NOT NULL DEFAULT 1
218
+ passed INTEGER DEFAULT 1 NOT NULL
210
219
  )
211
220
  SQL
212
221
  else
@@ -320,10 +329,13 @@ module Tina4
320
329
  Tina4::Log.info("Running migration: #{name}")
321
330
  begin
322
331
  # Wrap each migration FILE in its own transaction so a multi-statement
323
- # file that fails midway rolls back as a unit. Truly atomic only on
324
- # engines with transactional DDL (PostgreSQL); MySQL/Firebird/SQLite
325
- # auto-commit DDL, so earlier statements may persist there — keep one
326
- # logical change per file.
332
+ # file that fails midway rolls back as a unit. Truly atomic on engines
333
+ # with transactional DDL (PostgreSQL, and SQLite -- autocommit is off
334
+ # inside start_transaction, proven by
335
+ # spec/migration_contract_spec.rb's "a multi-statement failure rolls
336
+ # back the DDL on SQLite too"). MySQL and Firebird auto-commit DDL, so
337
+ # earlier statements may persist there -- keep one logical change per
338
+ # file on those two engines.
327
339
  @db.start_transaction
328
340
  if file.end_with?(".rb")
329
341
  execute_ruby_migration(file, :up)
@@ -349,23 +361,35 @@ module Tina4
349
361
 
350
362
  def rollback_migration(name)
351
363
  Tina4::Log.info("Rolling back: #{name}")
364
+ # FAIL-SAFE (MIG-DEC-02, reuses the Python reference model): each
365
+ # rollback is its own transaction, and the ledger row is removed ONLY
366
+ # once the down artifact actually ran (or was confirmed a deliberate
367
+ # no-op empty file) -- never on a missing or failed down. A missing
368
+ # artifact now RAISES instead of Tina4::Log.warning-then-still-deleting,
369
+ # which was the exact MIG-ROLLBACK-DROPS-LEDGER bug: the schema stayed
370
+ # applied but the tracking row vanished, so it was silently forgotten.
371
+ @db.start_transaction
352
372
  begin
353
373
  file = File.join(@migrations_dir, name)
354
- if name.end_with?(".rb") && File.exist?(file)
374
+ if name.end_with?(".rb")
375
+ raise "Cannot rollback #{name}: no .rb file found" unless File.exist?(file)
376
+
355
377
  execute_ruby_migration(file, :down)
356
378
  elsif name.end_with?(".sql")
357
- down_file = File.join(@migrations_dir, name.sub(".sql", ".down.sql"))
358
- if File.exist?(down_file)
359
- execute_sql_file(down_file)
360
- else
361
- Tina4::Log.warning("No rollback file for: #{name}")
362
- end
379
+ down_file = File.join(@migrations_dir, name.sub(/\.sql\z/, ".down.sql"))
380
+ raise "Cannot rollback #{name}: no .down.sql file found" unless File.exist?(down_file)
381
+
382
+ execute_sql_file(down_file)
383
+ else
384
+ raise "Cannot rollback #{name}: unrecognised migration file type"
363
385
  end
364
386
  _remove_migration_record(name)
387
+ @db.commit
365
388
  { name: name, status: "rolled_back" }
366
389
  rescue => e
390
+ @db.rollback rescue nil
367
391
  Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
368
- { name: name, status: "failed", error: e.message }
392
+ raise
369
393
  end
370
394
  end
371
395
 
@@ -593,9 +617,17 @@ module Tina4
593
617
 
594
618
  # Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
595
619
  # Firebird stores unquoted identifiers in upper-case.
620
+ #
621
+ # The selected literal is ALIASED ("1 AS FOUND", not a bare "1"): #fetch_one
622
+ # applies limit: 1, and FirebirdDriver#apply_limit wraps the query in a
623
+ # derived table ("SELECT FIRST 1 SKIP 0 * FROM (<sql>)") -- Firebird REQUIRES
624
+ # every derived-table column to have a name, and a bare unaliased literal has
625
+ # none ("no column name specified for column number 1 in derived table",
626
+ # SQL error -104). Found on a REAL Firebird 5 (MIG-FBMSSQL-MOCK, feature 15)
627
+ # -- the old FakeDB-based spec never executed a real query here at all.
596
628
  def firebird_column_exists?(table, column)
597
629
  row = @db.fetch_one(
598
- "SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
630
+ "SELECT 1 AS FOUND FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
599
631
  [table.upcase, column.upcase]
600
632
  )
601
633
  !row.nil?