tina4ruby 3.10.24 → 3.10.30

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: 9f356bd29729293ca6cbc614d058d1f36e587d24734ac95dc09dc2cb0dc8f106
4
+ data.tar.gz: 2fa1c1a2bd95599261b53b1790e27401abf8a9a9e308d03a05e33ccdafcbe7c2
5
5
  SHA512:
6
- metadata.gz: a10cdebeca708e63aebaa1f75e8d18e1b77429560f42a0bedcec9247792c9ce7451d93a233f4b6c1bdd8865e7ce4bae3440ae33cded8974e8dbcf4cfac060e93
7
- data.tar.gz: f613505809da1d5d5868e52a368f09a82e7f24905f5f51011b6747d4b45877e4151d5830f45a943764ba84f42d5b76aa40a47284038dafb8d40406164d748570
6
+ metadata.gz: ff0588fb70c881e2b1a0a696df8b2ae1f7067b61c85dc29e64268a4f19b26e552e018c22321574e45b97814f86d708691f78120c74c7aaac0c76ad77f45bdbf4
7
+ data.tar.gz: c64f603f1d3cf692cfbbf45e476e16f7c69be7e4a0d127f9d10b3144b64edf3b82704ad2affdfc7ea127cbd67bef51c55b0b7f5416e741ef1c1210a6d75affdb
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
@@ -1423,7 +1423,7 @@ module Tina4
1423
1423
  param_names.each_with_index do |pname, pi|
1424
1424
  macro_ctx[pname] = pi < args.length ? args[pi] : nil
1425
1425
  end
1426
- engine.send(:render_tokens, captured_body.dup, macro_ctx)
1426
+ Tina4::SafeString.new(engine.send(:render_tokens, captured_body.dup, macro_ctx))
1427
1427
  }
1428
1428
 
1429
1429
  i
@@ -1480,7 +1480,7 @@ module Tina4
1480
1480
  param_names.each_with_index do |pname, pi|
1481
1481
  macro_ctx[pname] = pi < args.length ? args[pi] : nil
1482
1482
  end
1483
- engine.send(:render_tokens, body_tokens.dup, macro_ctx)
1483
+ Tina4::SafeString.new(engine.send(:render_tokens, body_tokens.dup, macro_ctx))
1484
1484
  }
1485
1485
  end
1486
1486
 
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.30"
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,13 +1,14 @@
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.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-03-30 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rack
@@ -399,6 +400,7 @@ licenses:
399
400
  - MIT
400
401
  metadata:
401
402
  homepage_uri: https://tina4.com
403
+ post_install_message:
402
404
  rdoc_options: []
403
405
  require_paths:
404
406
  - lib
@@ -413,7 +415,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
413
415
  - !ruby/object:Gem::Version
414
416
  version: '0'
415
417
  requirements: []
416
- rubygems_version: 4.0.3
418
+ rubygems_version: 3.4.19
419
+ signing_key:
417
420
  specification_version: 4
418
421
  summary: Simple. Fast. Human. This is not a framework.
419
422
  test_files: []