tina4ruby 3.13.36 → 3.13.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,24 +41,44 @@ module Tina4
41
41
  @global_middleware = []
42
42
  end
43
43
 
44
- # Run all "before" hooks: block-based handlers, then class-based before_* methods.
44
+ # Run all "before" hooks: block-based handlers, then class-based before_*
45
+ # methods (in definition order).
45
46
  #
46
47
  # Signature matches Python/PHP/Node orchestrators: pass the list of
47
48
  # middleware classes explicitly.
48
49
  #
49
- # Returns [request, response] on success, or false to halt the request.
50
+ # M2 visible-but-resilient: every before_* call is wrapped so a THROW
51
+ # never crashes the worker. On a throw the error is LOGGED and the
52
+ # response becomes a clean 500 ({"error":"Internal Server Error",
53
+ # "status":500}), then processing halts (handler skipped) — deterministic,
54
+ # never an unhandled exception. A before_* that sets status >= 400 also
55
+ # halts (the existing 4xx short-circuit). after_* still run on either
56
+ # halt path (see the dispatcher / #run_after docstring).
57
+ #
58
+ # Returns true on success, or false to halt the request (handler skipped).
50
59
  def run_before(middleware_classes, request, response)
51
60
  # 1. Block-based before handlers (pattern-matched)
52
61
  before_handlers.each do |entry|
53
62
  next unless matches_pattern?(request.path, entry[:pattern])
54
- result = entry[:handler].call(request, response)
63
+
64
+ begin
65
+ result = entry[:handler].call(request, response)
66
+ rescue StandardError, ScriptError => error
67
+ middleware_500(response, "before handler", error)
68
+ return false
69
+ end
55
70
  return false if result == false
56
71
  end
57
72
 
58
- # 2. Class-based middleware: call every before_* method
73
+ # 2. Class-based middleware: call every before_* method (definition order)
59
74
  middleware_classes.each do |klass|
60
75
  before_methods_for(klass).each do |method_name|
61
- result = klass.send(method_name, request, response)
76
+ begin
77
+ result = klass.send(method_name, request, response)
78
+ rescue StandardError, ScriptError => error
79
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
80
+ return false
81
+ end
62
82
  # Support returning [request, response] (Python convention) or false to halt
63
83
  if result == false
64
84
  return false
@@ -73,21 +93,42 @@ module Tina4
73
93
  true
74
94
  end
75
95
 
76
- # Run all "after" hooks: block-based handlers, then class-based after_* methods.
96
+ # Run all "after" hooks: block-based handlers, then class-based after_*
97
+ # methods (in definition order).
77
98
  #
78
99
  # Signature matches Python/PHP/Node orchestrators: pass the list of
79
100
  # middleware classes explicitly.
101
+ #
102
+ # AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
103
+ # after_* ALWAYS run even when a before_* short-circuited with status >= 400
104
+ # and the handler was skipped — so they can still add headers / logging.
105
+ # The dispatcher calls #run_after unconditionally after the before/handler
106
+ # block (including on the 4xx / throw halt path).
107
+ #
108
+ # M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
109
+ # response into a clean 500, then the REMAINING after_* still run (they
110
+ # may add headers/logging). Never an unhandled crash.
80
111
  def run_after(middleware_classes, request, response)
81
112
  # 1. Block-based after handlers (pattern-matched)
82
113
  after_handlers.each do |entry|
83
114
  next unless matches_pattern?(request.path, entry[:pattern])
84
- entry[:handler].call(request, response)
115
+
116
+ begin
117
+ entry[:handler].call(request, response)
118
+ rescue StandardError, ScriptError => error
119
+ middleware_500(response, "after handler", error)
120
+ end
85
121
  end
86
122
 
87
- # 2. Class-based middleware: call every after_* method
123
+ # 2. Class-based middleware: call every after_* method (definition order)
88
124
  middleware_classes.each do |klass|
89
125
  after_methods_for(klass).each do |method_name|
90
- result = klass.send(method_name, request, response)
126
+ begin
127
+ result = klass.send(method_name, request, response)
128
+ rescue StandardError, ScriptError => error
129
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
130
+ next
131
+ end
91
132
  if result.is_a?(Array) && result.length == 2
92
133
  request, response = result
93
134
  end
@@ -95,8 +136,38 @@ module Tina4
95
136
  end
96
137
  end
97
138
 
139
+ # Deterministic clean 500 for a middleware that threw. Logs the cause
140
+ # (NEVER silent) then sets the response to the canonical error shape —
141
+ # byte-identical to the Python master ({"error":"Internal Server Error",
142
+ # "status":500} + status 500). Returns the response for chaining.
143
+ def middleware_500(response, label, error)
144
+ begin
145
+ Tina4::Log.error(
146
+ "Middleware #{label} raised #{error.class.name}: #{error.message}"
147
+ )
148
+ rescue StandardError
149
+ begin
150
+ $stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
151
+ $stderr.flush
152
+ rescue StandardError
153
+ # never let logging break the worker
154
+ end
155
+ end
156
+ response.json({ error: "Internal Server Error", status: 500 }, 500)
157
+ end
158
+
98
159
  private
99
160
 
161
+ # Human-readable label for a middleware (class name, or the class of an
162
+ # instance) used in the logged 500 message.
163
+ def class_label(klass)
164
+ if klass.is_a?(Class) || klass.is_a?(Module)
165
+ klass.name || klass.to_s
166
+ else
167
+ klass.class.name || klass.class.to_s
168
+ end
169
+ end
170
+
100
171
  def matches_pattern?(path, pattern)
101
172
  return true if pattern.nil?
102
173
  case pattern
@@ -109,14 +180,66 @@ module Tina4
109
180
  end
110
181
  end
111
182
 
112
- # Collect all public class methods matching before_*
183
+ # Collect all class methods matching before_* in DEFINITION order.
113
184
  def before_methods_for(klass)
114
- klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
185
+ discover_methods(klass, "before_")
115
186
  end
116
187
 
117
- # Collect all public class methods matching after_*
188
+ # Collect all class methods matching after_* in DEFINITION order.
118
189
  def after_methods_for(klass)
119
- klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
190
+ discover_methods(klass, "after_")
191
+ end
192
+
193
+ # ----------------------------------------------------------------------
194
+ # MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
195
+ # in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
196
+ # natural iteration of the registered middleware list (registration
197
+ # order). before_* run before the handler, after_* after.
198
+ #
199
+ # WHY source line numbers, not instance_methods(false): in Ruby/PRISM
200
+ # `instance_methods(false)` is NOT a reliable definition-order report —
201
+ # once a method NAME (symbol) has been defined on any other class first,
202
+ # that name can sort ahead in a later class's list. So we sort the
203
+ # matching methods by their `source_location` line number, which IS the
204
+ # true source-definition order and is immune to the symbol-table quirk.
205
+ # (Methods with no source_location — e.g. C-defined — sort to the front
206
+ # deterministically by name.) We walk the ancestry base→derived so
207
+ # inherited middleware methods run before a subclass's own, de-duping
208
+ # overrides to their first (base) position. Mirrors the Python master's
209
+ # Middleware._discover_methods MRO walk (which leans on __dict__ insertion
210
+ # order — the equivalent of source-definition order).
211
+ def discover_methods(klass, prefix)
212
+ target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
213
+ seen = {}
214
+ names = []
215
+ target.ancestors.reverse_each do |ancestor|
216
+ matched = begin
217
+ ancestor.instance_methods(false).select do |name|
218
+ name.to_s.start_with?(prefix) &&
219
+ !seen.key?(name) &&
220
+ klass.respond_to?(name)
221
+ end
222
+ rescue StandardError
223
+ []
224
+ end
225
+
226
+ ordered = matched.sort_by.with_index do |name, idx|
227
+ line = begin
228
+ loc = ancestor.instance_method(name).source_location
229
+ loc ? loc[1] : -1
230
+ rescue StandardError
231
+ -1
232
+ end
233
+ # Tie-break on the symbol-table index so the result is total/stable.
234
+ [line, idx]
235
+ end
236
+
237
+ ordered.each do |name|
238
+ seen[name] = true
239
+ names << name
240
+ end
241
+ end
242
+ names
120
243
  end
121
244
  end
122
245
  end
@@ -356,10 +356,12 @@ module Tina4
356
356
  Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
357
357
  next
358
358
  end
359
- result = @db.execute(stmt)
360
- if result == false
361
- raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
362
- end
359
+ # db.execute() now RAISES on a SQL error (it no longer returns false),
360
+ # so a bad statement throws straight up to run_migration's rescue, which
361
+ # records the migration as failed and surfaces the error. The old
362
+ # "if result == false: raise" check is dead — a plain execute carries
363
+ # the same failure semantics.
364
+ @db.execute(stmt)
363
365
  end
364
366
  end
365
367
 
data/lib/tina4/orm.rb CHANGED
@@ -425,17 +425,20 @@ module Tina4
425
425
  translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine
426
426
  sql = SQLTranslator.auto_increment_syntax(sql, translator_engine)
427
427
 
428
- # Don't claim success when the DDL failed. db.execute() swallows the
429
- # driver error into get_error() and returns false, so a bad type (or
430
- # any DDL error) used to leave create_table returning true while no
431
- # table was actually created.
432
- ok = db.execute(sql)
433
- db.commit
434
- if ok == false
435
- Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error}", { sql: sql })
436
- return false
428
+ # Don't claim success when the DDL failed. db.execute() now RAISES on a
429
+ # SQL error (it no longer swallows it into get_error() and returns
430
+ # false), so a bad type (or any DDL error) surfaces here as an
431
+ # exception. create_table keeps its documented bool contract: catch the
432
+ # raise, log the cause, and return false so callers that test the return
433
+ # still see a clean failure instead of a thrown error.
434
+ begin
435
+ db.execute(sql)
436
+ db.commit
437
+ true
438
+ rescue => e
439
+ Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
440
+ false
437
441
  end
438
- true
439
442
  end
440
443
 
441
444
  def scope(name, filter_sql, params = [])