tina4ruby 3.10.24 → 3.10.31

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bbf81019e88de5f456938818de88c5f8219dcb691d647c26d504f6aacdf229a
4
- data.tar.gz: a0b70c415d26e8727cfec8337fc957a58cb4d4895565bc0c1922ef9ebd2383c8
3
+ metadata.gz: f58284436d1c4f21bf16c723f02d174c6b4090395eb59a8357387b776cdb3c07
4
+ data.tar.gz: e19f24eac4a4010d9377a74f7e5da9ab437c062b3c4773f1a0932995b661e298
5
5
  SHA512:
6
- metadata.gz: a10cdebeca708e63aebaa1f75e8d18e1b77429560f42a0bedcec9247792c9ce7451d93a233f4b6c1bdd8865e7ce4bae3440ae33cded8974e8dbcf4cfac060e93
7
- data.tar.gz: f613505809da1d5d5868e52a368f09a82e7f24905f5f51011b6747d4b45877e4151d5830f45a943764ba84f42d5b76aa40a47284038dafb8d40406164d748570
6
+ metadata.gz: 9191ea613953492469a000a221cc0c18f819eb48493c6a3128b04f5ef1e85d99ab08c29fe38ebbdb3b1fdfc85db1eaa648c6d327888d0f57ad19af7a14f4155d
7
+ data.tar.gz: 7d431e996a22db4a25879f148e891ceaeb6a7b8e186e2e5c9ccecb631234122c8a463b35f4bf4c963435973dc1b6c58dd819053ae2966917742c2baa0860a45d
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Tina4 Ruby</h1>
6
- <h3 align="center">This is not a framework</h3>
6
+ <h3 align="center">This Is Now A 4Framework</h3>
7
7
 
8
8
  <p align="center">
9
9
  Laravel joy. Ruby speed. 10x less code. Zero third-party dependencies.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "digest"
5
+ require "tmpdir"
4
6
 
5
7
  module Tina4
6
8
  # Thread-safe in-memory message log for dev dashboard
@@ -104,6 +106,171 @@ module Tina4
104
106
  end
105
107
  end
106
108
 
109
+ # Thread-safe, file-persisted error tracker for the dev dashboard Error Tracker panel.
110
+ #
111
+ # Errors are stored in a JSON file in the system temp directory keyed by
112
+ # project path, so they survive across requests and server restarts.
113
+ # Duplicate errors (same type + message + file + line) are de-duplicated —
114
+ # the count increments and the entry is re-opened if it was resolved.
115
+ class ErrorTracker
116
+ MAX_ERRORS = 200
117
+ private_constant :MAX_ERRORS
118
+
119
+ def initialize
120
+ @mutex = Mutex.new
121
+ @errors = nil # lazy-loaded
122
+ @store_path = File.join(
123
+ Dir.tmpdir,
124
+ "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
125
+ )
126
+ end
127
+
128
+ # Capture a Ruby error / exception into the tracker.
129
+ # @param error_type [String] e.g. "RuntimeError" or "NoMethodError"
130
+ # @param message [String] exception message
131
+ # @param traceback [String] formatted backtrace (optional)
132
+ # @param file [String] source file (optional)
133
+ # @param line [Integer] source line (optional)
134
+ def capture(error_type:, message:, traceback: "", file: "", line: 0)
135
+ @mutex.synchronize do
136
+ load_unlocked
137
+ fingerprint = Digest::MD5.hexdigest("#{error_type}|#{message}|#{file}|#{line}")
138
+ now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
139
+
140
+ if @errors.key?(fingerprint)
141
+ @errors[fingerprint][:count] += 1
142
+ @errors[fingerprint][:last_seen] = now
143
+ @errors[fingerprint][:resolved] = false # re-open resolved duplicates
144
+ else
145
+ @errors[fingerprint] = {
146
+ id: fingerprint,
147
+ error_type: error_type,
148
+ message: message,
149
+ traceback: traceback,
150
+ file: file,
151
+ line: line,
152
+ first_seen: now,
153
+ last_seen: now,
154
+ count: 1,
155
+ resolved: false
156
+ }
157
+ end
158
+ save_unlocked
159
+ end
160
+ end
161
+
162
+ # Capture a Ruby exception object directly.
163
+ def capture_exception(exc)
164
+ capture(
165
+ error_type: exc.class.name,
166
+ message: exc.message,
167
+ traceback: (exc.backtrace || []).first(20).join("\n"),
168
+ file: (exc.backtrace_locations&.first&.path || ""),
169
+ line: (exc.backtrace_locations&.first&.lineno || 0)
170
+ )
171
+ end
172
+
173
+ # Return all errors (newest first).
174
+ # @param include_resolved [Boolean]
175
+ def get(include_resolved: true)
176
+ @mutex.synchronize do
177
+ load_unlocked
178
+ entries = @errors.values
179
+ entries = entries.reject { |e| e[:resolved] } unless include_resolved
180
+ entries.sort_by { |e| e[:last_seen] }.reverse
181
+ end
182
+ end
183
+
184
+ # Count of unresolved errors.
185
+ def unresolved_count
186
+ @mutex.synchronize do
187
+ load_unlocked
188
+ @errors.count { |_, e| !e[:resolved] }
189
+ end
190
+ end
191
+
192
+ # Health summary (matches Python BrokenTracker interface).
193
+ def health
194
+ @mutex.synchronize do
195
+ load_unlocked
196
+ total = @errors.size
197
+ resolved = @errors.count { |_, e| e[:resolved] }
198
+ unresolved = total - resolved
199
+ { total: total, unresolved: unresolved, resolved: resolved, healthy: unresolved.zero? }
200
+ end
201
+ end
202
+
203
+ # Mark a single error as resolved.
204
+ def resolve(id)
205
+ @mutex.synchronize do
206
+ load_unlocked
207
+ return false unless @errors.key?(id)
208
+
209
+ @errors[id][:resolved] = true
210
+ save_unlocked
211
+ true
212
+ end
213
+ end
214
+
215
+ # Remove all resolved errors.
216
+ def clear_resolved
217
+ @mutex.synchronize do
218
+ load_unlocked
219
+ @errors.reject! { |_, e| e[:resolved] }
220
+ save_unlocked
221
+ end
222
+ end
223
+
224
+ # Remove ALL errors.
225
+ def clear_all
226
+ @mutex.synchronize do
227
+ @errors = {}
228
+ save_unlocked
229
+ end
230
+ end
231
+
232
+ # Reset (for testing).
233
+ def reset!
234
+ @mutex.synchronize do
235
+ @errors = {}
236
+ File.delete(@store_path) if File.exist?(@store_path)
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def load_unlocked
243
+ return if @errors
244
+
245
+ if File.exist?(@store_path)
246
+ raw = File.read(@store_path) rescue nil
247
+ data = raw ? (JSON.parse(raw, symbolize_names: true) rescue nil) : nil
248
+ if data.is_a?(Array)
249
+ # Re-key by id
250
+ @errors = {}
251
+ data.each { |e| @errors[e[:id]] = e if e[:id] }
252
+ else
253
+ @errors = {}
254
+ end
255
+ else
256
+ @errors = {}
257
+ end
258
+ end
259
+
260
+ def save_unlocked
261
+ # Trim to max, keeping newest last_seen
262
+ if @errors.size > MAX_ERRORS
263
+ sorted = @errors.values.sort_by { |e| e[:last_seen] }.last(MAX_ERRORS)
264
+ @errors = {}
265
+ sorted.each { |e| @errors[e[:id]] = e }
266
+ end
267
+
268
+ File.write(@store_path, JSON.generate(@errors.values))
269
+ rescue StandardError
270
+ # Best-effort persistence — never raise in a tracker
271
+ end
272
+ end
273
+
107
274
  # Developer dashboard module - only active in debug mode
108
275
  module DevAdmin
109
276
  class << self
@@ -119,6 +286,10 @@ module Tina4
119
286
  @mailbox ||= DevMailbox.new
120
287
  end
121
288
 
289
+ def error_tracker
290
+ @error_tracker ||= ErrorTracker.new
291
+ end
292
+
122
293
  def enabled?
123
294
  Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
124
295
  end
@@ -163,13 +334,16 @@ module Tina4
163
334
  messages = mailbox.inbox
164
335
  json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
165
336
  when ["GET", "/__dev/api/broken"]
166
- json_response({ errors: [], health: { total: 0, unresolved: 0, resolved: 0, healthy: true } })
337
+ errors = error_tracker.get(include_resolved: true)
338
+ h = error_tracker.health
339
+ json_response({ errors: errors, count: errors.size, health: h })
167
340
  when ["POST", "/__dev/api/broken/resolve"]
168
341
  body = read_json_body(env)
169
- # TODO: resolve tracked error by id from body["id"]
170
- json_response({ resolved: true })
342
+ id = body && body["id"]
343
+ resolved = id ? error_tracker.resolve(id) : false
344
+ json_response({ resolved: resolved, id: id })
171
345
  when ["POST", "/__dev/api/broken/clear"]
172
- # TODO: clear resolved errors
346
+ error_tracker.clear_resolved
173
347
  json_response({ cleared: true })
174
348
  when ["GET", "/__dev/api/websockets"]
175
349
  json_response({ connections: [], count: 0 })
@@ -301,7 +475,8 @@ module Tina4
301
475
  uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
302
476
  route_count: Tina4::Router.routes.size,
303
477
  request_stats: request_inspector.stats,
304
- message_counts: message_log.count
478
+ message_counts: message_log.count,
479
+ health: error_tracker.health
305
480
  }
306
481
  end
307
482
 
data/lib/tina4/frond.rb CHANGED
@@ -45,7 +45,7 @@ module Tina4
45
45
  HASH_LIT_RE = /\A\{(.+)\}\z/m
46
46
  HASH_PAIR_RE = /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
47
47
  RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
48
- ARITHMETIC_RE = /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
48
+ ARITHMETIC_OPS = [" + ", " - ", " * ", " // ", " / ", " % ", " ** "].freeze
49
49
  FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
50
50
  FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
51
51
  FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
@@ -933,12 +933,15 @@ module Tina4
933
933
  return eval_comparison(expr, context)
934
934
  end
935
935
 
936
- # Arithmetic: +, -, *, /, %
937
- if expr =~ ARITHMETIC_RE
938
- left = eval_expr(Regexp.last_match(1), context)
939
- op = Regexp.last_match(2)
940
- right = eval_expr(Regexp.last_match(3), context)
941
- return apply_math(left, op, right)
936
+ # Arithmetic: +, -, *, //, /, %, ** (lowest to highest precedence)
937
+ ARITHMETIC_OPS.each do |op|
938
+ pos = find_outside_quotes(expr, op)
939
+ next unless pos && pos >= 0
940
+ left_s = expr[0...pos].strip
941
+ right_s = expr[(pos + op.length)..].strip
942
+ l_val = eval_expr(left_s, context)
943
+ r_val = eval_expr(right_s, context)
944
+ return apply_math(l_val, op.strip, r_val)
942
945
  end
943
946
 
944
947
  # Function call: name(arg1, arg2)
@@ -1144,17 +1147,21 @@ module Tina4
1144
1147
  # -----------------------------------------------------------------------
1145
1148
 
1146
1149
  def apply_math(left, op, right)
1147
- l = left.to_f
1148
- r = right.to_f
1150
+ l = (left || 0).to_f
1151
+ r = (right || 0).to_f
1152
+ # Preserve int type when both operands are int-like (except for / which returns float)
1153
+ both_int = l == l.to_i && r == r.to_i && op != "/"
1149
1154
  result = case op
1150
- when "+" then l + r
1151
- when "-" then l - r
1152
- when "*" then l * r
1153
- when "/" then r != 0 ? l / r : 0
1154
- when "%" then l % r
1155
+ when "+" then l + r
1156
+ when "-" then l - r
1157
+ when "*" then l * r
1158
+ when "/" then r != 0 ? l / r : 0
1159
+ when "//" then r != 0 ? (l / r).floor : 0
1160
+ when "%" then r != 0 ? l % r : 0
1161
+ when "**" then l ** r
1155
1162
  else 0
1156
1163
  end
1157
- result == result.to_i ? result.to_i : result
1164
+ both_int && result == result.to_i ? result.to_i : result.to_f == result.to_i ? result.to_i : result
1158
1165
  end
1159
1166
 
1160
1167
  # -----------------------------------------------------------------------
@@ -1354,7 +1361,7 @@ module Tina4
1354
1361
  if content =~ SET_RE
1355
1362
  name = Regexp.last_match(1)
1356
1363
  expr = Regexp.last_match(2).strip
1357
- context[name] = eval_expr(expr, context)
1364
+ context[name] = eval_var_raw(expr, context)
1358
1365
  end
1359
1366
  end
1360
1367
 
@@ -1423,7 +1430,7 @@ module Tina4
1423
1430
  param_names.each_with_index do |pname, pi|
1424
1431
  macro_ctx[pname] = pi < args.length ? args[pi] : nil
1425
1432
  end
1426
- engine.send(:render_tokens, captured_body.dup, macro_ctx)
1433
+ Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
1427
1434
  }
1428
1435
 
1429
1436
  i
@@ -1480,7 +1487,7 @@ module Tina4
1480
1487
  param_names.each_with_index do |pname, pi|
1481
1488
  macro_ctx[pname] = pi < args.length ? args[pi] : nil
1482
1489
  end
1483
- engine.send(:render_tokens, body_tokens.dup, macro_ctx)
1490
+ Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
1484
1491
  }
1485
1492
  end
1486
1493
 
data/lib/tina4/orm.rb CHANGED
@@ -388,21 +388,22 @@ module Tina4
388
388
  pk = self.class.primary_key_field || :id
389
389
  pk_value = __send__(pk)
390
390
 
391
- if @persisted && pk_value
392
- filter = { pk => pk_value }
393
- data.delete(pk)
394
- # Remove mapped primary key too
395
- mapped_pk = self.class.field_mapping[pk.to_s]
396
- data.delete(mapped_pk.to_sym) if mapped_pk
397
- self.class.db.update(self.class.table_name, data, filter)
398
- else
399
- result = self.class.db.insert(self.class.table_name, data)
400
- if result[:last_id] && respond_to?("#{pk}=")
401
- __send__("#{pk}=", result[:last_id])
391
+ self.class.db.transaction do |db|
392
+ if @persisted && pk_value
393
+ filter = { pk => pk_value }
394
+ data.delete(pk)
395
+ # Remove mapped primary key too
396
+ mapped_pk = self.class.field_mapping[pk.to_s]
397
+ data.delete(mapped_pk.to_sym) if mapped_pk
398
+ db.update(self.class.table_name, data, filter)
399
+ else
400
+ result = db.insert(self.class.table_name, data)
401
+ if result[:last_id] && respond_to?("#{pk}=")
402
+ __send__("#{pk}=", result[:last_id])
403
+ end
404
+ @persisted = true
402
405
  end
403
- @persisted = true
404
406
  end
405
- self.class.db.commit
406
407
  true
407
408
  rescue => e
408
409
  @errors << e.message
@@ -414,17 +415,17 @@ module Tina4
414
415
  pk_value = __send__(pk)
415
416
  return false unless pk_value
416
417
 
417
- if self.class.soft_delete
418
- # Soft delete: set the flag
419
- self.class.db.update(
420
- self.class.table_name,
421
- { self.class.soft_delete_field => 1 },
422
- { pk => pk_value }
423
- )
424
- else
425
- self.class.db.delete(self.class.table_name, { pk => pk_value })
418
+ self.class.db.transaction do |db|
419
+ if self.class.soft_delete
420
+ db.update(
421
+ self.class.table_name,
422
+ { self.class.soft_delete_field => 1 },
423
+ { pk => pk_value }
424
+ )
425
+ else
426
+ db.delete(self.class.table_name, { pk => pk_value })
427
+ end
426
428
  end
427
- self.class.db.commit
428
429
  @persisted = false
429
430
  true
430
431
  end
@@ -434,8 +435,9 @@ module Tina4
434
435
  pk_value = __send__(pk)
435
436
  raise "Cannot delete: no primary key value" unless pk_value
436
437
 
437
- self.class.db.delete(self.class.table_name, { pk => pk_value })
438
- self.class.db.commit
438
+ self.class.db.transaction do |db|
439
+ db.delete(self.class.table_name, { pk => pk_value })
440
+ end
439
441
  @persisted = false
440
442
  true
441
443
  end
@@ -447,12 +449,13 @@ module Tina4
447
449
  pk_value = __send__(pk)
448
450
  raise "Cannot restore: no primary key value" unless pk_value
449
451
 
450
- self.class.db.update(
451
- self.class.table_name,
452
- { self.class.soft_delete_field => 0 },
453
- { pk => pk_value }
454
- )
455
- self.class.db.commit
452
+ self.class.db.transaction do |db|
453
+ db.update(
454
+ self.class.table_name,
455
+ { self.class.soft_delete_field => 0 },
456
+ { pk => pk_value }
457
+ )
458
+ end
456
459
  __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
457
460
  true
458
461
  end
@@ -433,7 +433,7 @@ module Tina4
433
433
  <div class="hero">
434
434
  <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
435
435
  <h1>Tina4Ruby</h1>
436
- <p class="tagline">This is not a framework</p>
436
+ <p class="tagline">This Is Now A 4Framework</p>
437
437
  <div class="actions">
438
438
  <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
439
439
  <a href="/__dev" class="btn">Dev Admin</a>
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.24"
4
+ VERSION = "3.10.31"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -134,7 +134,7 @@ module Tina4
134
134
  end
135
135
 
136
136
  puts "#{color}#{BANNER}#{reset}"
137
- puts " Tina4 Ruby v#{VERSION} — This is not a framework"
137
+ puts " Tina4 Ruby v#{VERSION} — This Is Now A 4Framework"
138
138
  puts ""
139
139
  puts " Server: http://#{display}:#{port} (#{server_name})"
140
140
  puts " Swagger: http://localhost:#{port}/swagger"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.24
4
+ version: 3.10.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team