tina4ruby 3.10.75 → 3.10.83

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: 46b70ee5c1e5fb119b04afdd515d32ecbabe757871929f0f9ec9557a99869c6c
4
- data.tar.gz: 932aee6552f3d94e1b69f03631ef1bae11cf88005465565b2c16bc09b997962a
3
+ metadata.gz: e7f1721eaefd19400dedcfdb298a6af42772aa824b9663a17d79bced2169161b
4
+ data.tar.gz: a712e6a50ea1ece57a4e4bc9e0e49c49dcb0f5e5c56471c1da26bd4f65c42d79
5
5
  SHA512:
6
- metadata.gz: a885f897b4e6e9bac9be3871f86071c7a6dd3a100cc110dacc69b8cc29eef86000a9f92b90129c5d22f54b7d934f66d7f8a8746763b4e214ea85827f884c8c2d
7
- data.tar.gz: ab11c9414db7fc6561fb572af1c29d47713cbc00b3cc2acc17f0d3673032e8843276b799d37526732a5281ede5a409a8ab7065361ac8e50a645934762cef3a2e
6
+ metadata.gz: 8f50ab9dbaf48c592766f8b13debe1086e7288184a69849346fae318aa270a275c28fb28399eac9a834ba3c18fb884babefc8d1f860b9326c06fdba3623b4a43
7
+ data.tar.gz: ab1063c1b9402c3445ee4d75fdf98eab9342560e3d1689085879a48309fbe3a9761a0ee1759f517ae0641b82f46f801b6585244f45aadd83f7dbdddfd4c54f03
data/lib/tina4/auth.rb CHANGED
@@ -12,7 +12,7 @@ module Tina4
12
12
  def setup(root_dir = Dir.pwd)
13
13
  @keys_dir = File.join(root_dir, KEYS_DIR)
14
14
  FileUtils.mkdir_p(@keys_dir)
15
- ensure_keys unless use_hmac?
15
+ ensure_keys
16
16
  end
17
17
 
18
18
  # ── HS256 helpers (stdlib only, no gem) ──────────────────────
@@ -258,8 +258,6 @@ module Tina4
258
258
  private
259
259
 
260
260
  def ensure_keys
261
- return if use_hmac?
262
-
263
261
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
264
262
  FileUtils.mkdir_p(@keys_dir)
265
263
  unless File.exist?(private_key_path) && File.exist?(public_key_path)
@@ -97,7 +97,7 @@ module Tina4
97
97
  Tina4::Router.add_route("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
98
98
  begin
99
99
  id = req.params["id"]
100
- record = model_class.find(id.to_i)
100
+ record = model_class.find_by_id(id.to_i)
101
101
  if record
102
102
  res.json({ data: record.to_h })
103
103
  else
@@ -140,7 +140,7 @@ module Tina4
140
140
  Tina4::Router.add_route("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
141
141
  begin
142
142
  id = req.params["id"]
143
- record = model_class.find(id.to_i)
143
+ record = model_class.find_by_id(id.to_i)
144
144
  unless record
145
145
  next res.json({ error: "Not found" }, status: 404)
146
146
  end
@@ -178,7 +178,7 @@ module Tina4
178
178
  Tina4::Router.add_route("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
179
179
  begin
180
180
  id = req.params["id"]
181
- record = model_class.find(id.to_i)
181
+ record = model_class.find_by_id(id.to_i)
182
182
  unless record
183
183
  next res.json({ error: "Not found" }, status: 404)
184
184
  end
@@ -631,16 +631,12 @@ module Tina4
631
631
 
632
632
  # Execute all statements (single write or multi-statement batch)
633
633
  total_affected = 0
634
- db.start_transaction if db.respond_to?(:start_transaction)
635
- begin
636
- statements.each do |stmt|
637
- result = db.execute(stmt)
638
- total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
634
+ statements.each do |stmt|
635
+ result = db.execute(stmt)
636
+ if result == false
637
+ return { error: db.get_error || "Statement failed: #{stmt}" }
639
638
  end
640
- db.commit if db.respond_to?(:commit)
641
- rescue => e
642
- db.rollback if db.respond_to?(:rollback)
643
- return { error: e.message }
639
+ total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
644
640
  end
645
641
 
646
642
  { affected: total_affected, success: true }
data/lib/tina4/graphql.rb CHANGED
@@ -130,7 +130,7 @@ module Tina4
130
130
  add_query(table_lower, type: type_name,
131
131
  args: { pk_field => { type: "ID!" } },
132
132
  description: "Fetch a single #{model_name} by #{pk_field}") do |_root, args, _ctx|
133
- record = klass.find(args[pk_field])
133
+ record = klass.find_by_id(args[pk_field])
134
134
  record&.to_hash
135
135
  end
136
136
 
@@ -158,7 +158,7 @@ module Tina4
158
158
  add_mutation("update#{model_name}", type: type_name,
159
159
  args: { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } },
160
160
  description: "Update an existing #{model_name}") do |_root, args, _ctx|
161
- record = klass.find(args[pk_field])
161
+ record = klass.find_by_id(args[pk_field])
162
162
  return nil unless record
163
163
  (args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
164
164
  record.save
@@ -169,7 +169,7 @@ module Tina4
169
169
  add_mutation("delete#{model_name}", type: "Boolean",
170
170
  args: { pk_field => { type: "ID!" } },
171
171
  description: "Delete a #{model_name} by #{pk_field}") do |_root, args, _ctx|
172
- record = klass.find(args[pk_field])
172
+ record = klass.find_by_id(args[pk_field])
173
173
  return false unless record
174
174
  record.delete
175
175
  true
@@ -769,7 +769,7 @@ module Tina4
769
769
  end
770
770
 
771
771
  # Return schema as GraphQL SDL string.
772
- def schema
772
+ def schema_sdl
773
773
  sdl = ""
774
774
  @schema.types.each do |name, type_obj|
775
775
  sdl += "type #{name} {\n"
data/lib/tina4/job.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Tina4
6
+ class Job
7
+ attr_reader :id, :topic, :payload, :created_at, :attempts, :priority, :available_at
8
+ attr_accessor :status
9
+
10
+ def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0)
11
+ @id = id || SecureRandom.uuid
12
+ @topic = topic
13
+ @payload = payload
14
+ @created_at = Time.now
15
+ @attempts = attempts
16
+ @priority = priority
17
+ @available_at = available_at
18
+ @status = :pending
19
+ end
20
+
21
+ # Re-queue this message with incremented attempts.
22
+ # Delegates to the queue's backend via the queue reference.
23
+ def retry(queue:, delay_seconds: 0)
24
+ @attempts += 1
25
+ @status = :pending
26
+ @available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
27
+ queue.backend.enqueue(self)
28
+ self
29
+ end
30
+
31
+ def to_array
32
+ [@id, @topic, @payload, @priority, @attempts]
33
+ end
34
+
35
+ def to_hash
36
+ h = {
37
+ id: @id,
38
+ topic: @topic,
39
+ payload: @payload,
40
+ created_at: @created_at.iso8601,
41
+ attempts: @attempts,
42
+ status: @status,
43
+ priority: @priority
44
+ }
45
+ h[:available_at] = @available_at.iso8601 if @available_at
46
+ h
47
+ end
48
+
49
+ def to_json(*_args)
50
+ JSON.generate(to_hash)
51
+ end
52
+
53
+ def increment_attempts!
54
+ @attempts += 1
55
+ end
56
+
57
+ # Mark this job as completed.
58
+ def complete
59
+ @status = :completed
60
+ end
61
+
62
+ # Mark this job as failed with a reason.
63
+ def fail(reason = "")
64
+ @status = :failed
65
+ @error = reason
66
+ @attempts += 1
67
+ end
68
+
69
+ # Reject this job with a reason. Alias for fail().
70
+ def reject(reason = "")
71
+ fail(reason)
72
+ end
73
+
74
+ attr_reader :error
75
+ end
76
+ end
@@ -288,7 +288,10 @@ module Tina4
288
288
  Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
289
289
  next
290
290
  end
291
- @db.execute(stmt)
291
+ result = @db.execute(stmt)
292
+ if result == false
293
+ raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
294
+ end
292
295
  end
293
296
  end
294
297
 
data/lib/tina4/orm.rb CHANGED
@@ -61,6 +61,18 @@ module Tina4
61
61
  @auto_map = val
62
62
  end
63
63
 
64
+ # Auto-CRUD flag: when set to true, registers this model for CRUD route generation
65
+ def auto_crud
66
+ @auto_crud || false
67
+ end
68
+
69
+ def auto_crud=(val)
70
+ @auto_crud = val
71
+ if val
72
+ Tina4::AutoCrud.register(self) if defined?(Tina4::AutoCrud)
73
+ end
74
+ end
75
+
64
76
  # Relationship definitions
65
77
  def relationship_definitions
66
78
  @relationship_definitions ||= {}
@@ -115,27 +127,42 @@ module Tina4
115
127
  QueryBuilder.from(table_name, db: db)
116
128
  end
117
129
 
118
- def find(id_or_filter = nil, filter = nil, **kwargs)
119
- include_list = kwargs.delete(:include)
120
-
121
- # find(id) find by primary key
122
- # find(filter_hash) find by criteria
123
- # find(name: "Alice") keyword args as filter hash
124
- result = if id_or_filter.is_a?(Hash)
125
- find_by_filter(id_or_filter)
126
- elsif filter.is_a?(Hash)
127
- find_by_filter(filter)
128
- elsif !kwargs.empty?
129
- find_by_filter(kwargs)
130
- else
131
- find_by_id(id_or_filter)
130
+ # Find records by filter dict. Always returns an array.
131
+ #
132
+ # Usage:
133
+ # User.find(name: "Alice") [User, ...]
134
+ # User.find({age: 18}, limit: 10) → [User, ...]
135
+ # User.find(order_by: "name ASC") [User, ...]
136
+ # User.find → all records
137
+ #
138
+ # Use find_by_id(id) for single-record primary key lookup.
139
+ def find(filter = {}, limit: 100, offset: 0, order_by: nil, include: nil, **extra_filter)
140
+ # Integer or string-digit argument → primary key lookup (returns single record or nil)
141
+ return find_by_id(filter) if filter.is_a?(Integer)
142
+
143
+ # Merge keyword-style filters: find(name: "Alice") and find({name: "Alice"}) both work
144
+ filter = filter.merge(extra_filter) unless extra_filter.empty?
145
+ conditions = []
146
+ params = []
147
+
148
+ filter.each do |key, value|
149
+ col = field_mapping[key.to_s] || key
150
+ conditions << "#{col} = ?"
151
+ params << value
132
152
  end
133
153
 
134
- if include_list && result
135
- instances = result.is_a?(Array) ? result : [result]
136
- eager_load(instances, include_list)
154
+ if soft_delete
155
+ conditions << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
137
156
  end
138
- result
157
+
158
+ sql = "SELECT * FROM #{table_name}"
159
+ sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
160
+ sql += " ORDER BY #{order_by}" if order_by
161
+
162
+ results = db.fetch(sql, params, limit: limit, offset: offset)
163
+ instances = results.map { |row| from_hash(row) }
164
+ eager_load(instances, include) if include
165
+ instances
139
166
  end
140
167
 
141
168
  # Eager load relationships for a collection of instances (prevents N+1).
@@ -343,15 +370,7 @@ module Tina4
343
370
  instance
344
371
  end
345
372
 
346
- private
347
-
348
- def auto_discover_db
349
- url = ENV["DATABASE_URL"]
350
- return nil unless url
351
- Tina4.database = Tina4::Database.new(url, username: ENV.fetch("DATABASE_USERNAME", ""), password: ENV.fetch("DATABASE_PASSWORD", ""))
352
- Tina4.database
353
- end
354
-
373
+ # Find a single record by primary key. Returns instance or nil.
355
374
  def find_by_id(id)
356
375
  pk = primary_key_field || :id
357
376
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
@@ -361,6 +380,15 @@ module Tina4
361
380
  select_one(sql, [id])
362
381
  end
363
382
 
383
+ private
384
+
385
+ def auto_discover_db
386
+ url = ENV["DATABASE_URL"]
387
+ return nil unless url
388
+ Tina4.database = Tina4::Database.new(url, username: ENV.fetch("DATABASE_USERNAME", ""), password: ENV.fetch("DATABASE_PASSWORD", ""))
389
+ Tina4.database
390
+ end
391
+
364
392
  def find_by_filter(filter)
365
393
  where_parts = filter.keys.map { |k| "#{k} = ?" }
366
394
  sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
@@ -414,7 +442,7 @@ module Tina4
414
442
  @persisted = true
415
443
  end
416
444
  end
417
- self
445
+ true
418
446
  rescue => e
419
447
  @errors << e.message
420
448
  false
@@ -483,8 +511,29 @@ module Tina4
483
511
 
484
512
  # Load a record into this instance via select_one.
485
513
  # Returns true if found and loaded, false otherwise.
486
- def load(sql, params = [], include: nil)
514
+ # Load a record into this instance.
515
+ #
516
+ # Usage:
517
+ # orm.id = 1; orm.load — uses PK already set
518
+ # orm.load("id = ?", [1]) — filter with params
519
+ # orm.load("id = 1") — filter string
520
+ #
521
+ # Returns true if a record was found, false otherwise.
522
+ def load(filter = nil, params = [], include: nil)
487
523
  @relationship_cache = {}
524
+ table = self.class.table_name
525
+
526
+ if filter.nil?
527
+ pk = self.class.primary_key
528
+ pk_col = self.class.field_mapping[pk.to_s] || pk
529
+ pk_value = __send__(pk)
530
+ return false if pk_value.nil?
531
+ sql = "SELECT * FROM #{table} WHERE #{pk_col} = ?"
532
+ params = [pk_value]
533
+ else
534
+ sql = "SELECT * FROM #{table} WHERE #{filter}"
535
+ end
536
+
488
537
  result = self.class.select_one(sql, params, include: include)
489
538
  return false unless result
490
539
 
@@ -634,7 +683,7 @@ module Tina4
634
683
  fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
635
684
  return nil unless fk_value
636
685
 
637
- @relationship_cache[name] = klass.find(fk_value)
686
+ @relationship_cache[name] = klass.find_by_id(fk_value)
638
687
  end
639
688
 
640
689
  public
@@ -671,7 +720,7 @@ module Tina4
671
720
  fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
672
721
  return nil unless fk_value
673
722
 
674
- related_class.find(fk_value)
723
+ related_class.find_by_id(fk_value)
675
724
  end
676
725
 
677
726
  # Instance-level aliases matching Python/PHP/Node.js naming
data/lib/tina4/queue.rb CHANGED
@@ -1,75 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
3
  require "securerandom"
4
+ require_relative "job"
4
5
 
5
6
  module Tina4
6
- class QueueMessage
7
- attr_reader :id, :topic, :payload, :created_at, :attempts, :priority, :available_at
8
- attr_accessor :status
9
-
10
- def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0)
11
- @id = id || SecureRandom.uuid
12
- @topic = topic
13
- @payload = payload
14
- @created_at = Time.now
15
- @attempts = attempts
16
- @priority = priority
17
- @available_at = available_at
18
- @status = :pending
19
- end
20
-
21
- # Re-queue this message with incremented attempts.
22
- # Delegates to the queue's backend via the queue reference.
23
- def retry(queue:, delay_seconds: 0)
24
- @attempts += 1
25
- @status = :pending
26
- @available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
27
- queue.backend.enqueue(self)
28
- self
29
- end
30
-
31
- def to_hash
32
- h = {
33
- id: @id,
34
- topic: @topic,
35
- payload: @payload,
36
- created_at: @created_at.iso8601,
37
- attempts: @attempts,
38
- status: @status,
39
- priority: @priority
40
- }
41
- h[:available_at] = @available_at.iso8601 if @available_at
42
- h
43
- end
44
-
45
- def to_json(*_args)
46
- JSON.generate(to_hash)
47
- end
48
-
49
- def increment_attempts!
50
- @attempts += 1
51
- end
52
-
53
- # Mark this job as completed.
54
- def complete
55
- @status = :completed
56
- end
57
-
58
- # Mark this job as failed with a reason.
59
- def fail(reason = "")
60
- @status = :failed
61
- @error = reason
62
- @attempts += 1
63
- end
64
-
65
- # Reject this job with a reason. Alias for fail().
66
- def reject(reason = "")
67
- fail(reason)
68
- end
69
-
70
- attr_reader :error
71
- end
72
-
73
7
  # Queue — unified wrapper for queue management operations.
74
8
  # Auto-detects backend from TINA4_QUEUE_BACKEND env var.
75
9
  #
@@ -91,21 +25,39 @@ module Tina4
91
25
  @backend = resolve_backend_arg(backend)
92
26
  end
93
27
 
94
- # Push a job onto the queue. Returns the QueueMessage.
28
+ # Push a job onto the queue. Returns the Job.
95
29
  # priority: higher-priority messages are dequeued first (default 0).
96
30
  # delay_seconds: delay before the message becomes available (default 0).
97
31
  def push(payload, priority: 0, delay_seconds: 0)
98
32
  available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
99
- message = QueueMessage.new(topic: @topic, payload: payload, priority: priority, available_at: available_at)
33
+ message = Job.new(topic: @topic, payload: payload, priority: priority, available_at: available_at)
100
34
  @backend.enqueue(message)
101
35
  message
102
36
  end
103
37
 
104
- # Pop the next available job. Returns QueueMessage or nil.
38
+ # Pop the next available job. Returns Job or nil.
105
39
  def pop
106
40
  @backend.dequeue(@topic)
107
41
  end
108
42
 
43
+ # Clear all pending jobs from this queue's topic. Returns count removed.
44
+ def clear
45
+ return 0 unless @backend.respond_to?(:clear)
46
+ @backend.clear(@topic)
47
+ end
48
+
49
+ # Get jobs that failed but are still eligible for retry (under max_retries).
50
+ def failed
51
+ return [] unless @backend.respond_to?(:failed)
52
+ @backend.failed(@topic, max_retries: @max_retries)
53
+ end
54
+
55
+ # Retry a specific failed job by ID. Returns true if found and re-queued.
56
+ def retry(job_id, delay_seconds: 0)
57
+ return false unless @backend.respond_to?(:retry_job)
58
+ @backend.retry_job(@topic, job_id, delay_seconds: delay_seconds)
59
+ end
60
+
109
61
  # Get dead letter jobs — messages that exceeded max retries.
110
62
  def dead_letters
111
63
  return [] unless @backend.respond_to?(:dead_letters)
@@ -126,8 +78,8 @@ module Tina4
126
78
  end
127
79
 
128
80
  # Produce a message onto a topic. Convenience wrapper around push().
129
- def produce(topic, payload)
130
- message = QueueMessage.new(topic: topic, payload: payload)
81
+ def produce(topic, payload, priority: 0)
82
+ message = Job.new(topic: topic, payload: payload, priority: priority)
131
83
  @backend.enqueue(message)
132
84
  message
133
85
  end
@@ -156,7 +108,7 @@ module Tina4
156
108
  # queue.consume("emails", poll_interval: 5) { |job| process(job) }
157
109
  # queue.consume("emails", id: "abc-123") { |job| process(job) }
158
110
  #
159
- def consume(topic = nil, id: nil, poll_interval: 1.0, &block)
111
+ def consume(topic = nil, id: nil, poll_interval: 1.0, iterations: 0, &block)
160
112
  topic ||= @topic
161
113
 
162
114
  if id
@@ -170,7 +122,9 @@ module Tina4
170
122
 
171
123
  # poll_interval=0 → single-pass drain (returns when empty)
172
124
  # poll_interval>0 → long-running poll (sleeps when empty, never returns)
125
+ # iterations>0 → stop after consuming N jobs
173
126
  if block_given?
127
+ consumed = 0
174
128
  loop do
175
129
  job = @backend.dequeue(topic)
176
130
  if job.nil?
@@ -179,9 +133,12 @@ module Tina4
179
133
  next
180
134
  end
181
135
  yield job
136
+ consumed += 1
137
+ break if iterations > 0 && consumed >= iterations
182
138
  end
183
139
  else
184
140
  Enumerator.new do |yielder|
141
+ consumed = 0
185
142
  loop do
186
143
  job = @backend.dequeue(topic)
187
144
  if job.nil?
@@ -190,15 +147,17 @@ module Tina4
190
147
  next
191
148
  end
192
149
  yielder << job
150
+ consumed += 1
151
+ break if iterations > 0 && consumed >= iterations
193
152
  end
194
153
  end
195
154
  end
196
155
  end
197
156
 
198
157
  # Pop a specific job by ID from the queue.
199
- def pop_by_id(topic, id)
158
+ def pop_by_id(id)
200
159
  return nil unless @backend.respond_to?(:find_by_id)
201
- @backend.find_by_id(topic, id)
160
+ @backend.find_by_id(@topic, id)
202
161
  end
203
162
 
204
163
  # Get the number of messages by status.
@@ -45,7 +45,7 @@ module Tina4
45
45
  data = JSON.parse(msg.payload)
46
46
  @last_message = msg
47
47
 
48
- Tina4::QueueMessage.new(
48
+ Tina4::Job.new(
49
49
  topic: data["topic"],
50
50
  payload: data["payload"],
51
51
  id: data["id"]
@@ -63,7 +63,7 @@ module Tina4
63
63
  end
64
64
 
65
65
  def dead_letter(message)
66
- dead_msg = Tina4::QueueMessage.new(
66
+ dead_msg = Tina4::Job.new(
67
67
  topic: "#{message.topic}.dead_letter",
68
68
  payload: message.payload,
69
69
  id: message.id
@@ -52,7 +52,7 @@ module Tina4
52
52
  File.delete(chosen[:file])
53
53
  data = chosen[:data]
54
54
 
55
- Tina4::QueueMessage.new(
55
+ Tina4::Job.new(
56
56
  topic: data["topic"] || topic.to_s,
57
57
  payload: data["payload"],
58
58
  id: data["id"],
@@ -176,7 +176,7 @@ module Tina4
176
176
  next if (data["attempts"] || 0) >= max_retries
177
177
 
178
178
  data["status"] = "pending"
179
- msg = Tina4::QueueMessage.new(
179
+ msg = Tina4::Job.new(
180
180
  topic: data["topic"],
181
181
  payload: data["payload"],
182
182
  id: data["id"]
@@ -191,6 +191,57 @@ module Tina4
191
191
  count
192
192
  end
193
193
 
194
+ # Remove all pending jobs from a topic. Returns count removed.
195
+ def clear(topic)
196
+ dir = topic_path(topic)
197
+ return 0 unless Dir.exist?(dir)
198
+ count = 0
199
+ Dir.glob(File.join(dir, "*.json")).each do |file|
200
+ File.delete(file)
201
+ count += 1
202
+ end
203
+ count
204
+ end
205
+
206
+ # Get jobs that failed but are still eligible for retry (under max_retries).
207
+ def failed(topic, max_retries: 3)
208
+ return [] unless Dir.exist?(@dead_letter_dir)
209
+ jobs = []
210
+ Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }.each do |file|
211
+ data = JSON.parse(File.read(file))
212
+ next unless data["topic"] == topic.to_s
213
+ next if (data["attempts"] || 0) >= max_retries
214
+ jobs << data
215
+ rescue JSON::ParserError
216
+ next
217
+ end
218
+ jobs
219
+ end
220
+
221
+ # Retry a specific failed job by ID. Returns true if found and re-queued.
222
+ def retry_job(topic, job_id, delay_seconds: 0)
223
+ return false unless Dir.exist?(@dead_letter_dir)
224
+ file = File.join(@dead_letter_dir, "#{job_id}.json")
225
+ return false unless File.exist?(file)
226
+
227
+ data = JSON.parse(File.read(file))
228
+ return false unless data["topic"] == topic.to_s
229
+
230
+ available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
231
+ msg = Tina4::Job.new(
232
+ topic: data["topic"],
233
+ payload: data["payload"],
234
+ id: data["id"],
235
+ attempts: (data["attempts"] || 0) + 1,
236
+ available_at: available_at
237
+ )
238
+ enqueue(msg)
239
+ File.delete(file)
240
+ true
241
+ rescue JSON::ParserError
242
+ false
243
+ end
244
+
194
245
  private
195
246
 
196
247
  def topic_path(topic)
@@ -49,7 +49,7 @@ module Tina4
49
49
  )
50
50
  return nil unless doc
51
51
 
52
- Tina4::QueueMessage.new(
52
+ Tina4::Job.new(
53
53
  topic: doc["topic"],
54
54
  payload: doc["payload"],
55
55
  id: doc["_id"]
@@ -82,7 +82,7 @@ module Tina4
82
82
 
83
83
  def dead_letters(topic, max_retries: 3)
84
84
  collection.find(topic: "#{topic}.dead_letter", status: "dead").map do |doc|
85
- Tina4::QueueMessage.new(
85
+ Tina4::Job.new(
86
86
  topic: doc["topic"],
87
87
  payload: doc["payload"],
88
88
  id: doc["_id"]
@@ -31,7 +31,7 @@ module Tina4
31
31
  return nil unless payload
32
32
 
33
33
  data = JSON.parse(payload)
34
- msg = Tina4::QueueMessage.new(
34
+ msg = Tina4::Job.new(
35
35
  topic: data["topic"],
36
36
  payload: data["payload"],
37
37
  id: data["id"]
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.75"
4
+ VERSION = "3.10.83"
5
5
  end
@@ -2,6 +2,7 @@
2
2
  require "socket"
3
3
  require "digest"
4
4
  require "base64"
5
+ require "set"
5
6
 
6
7
  module Tina4
7
8
  class WebSocket
@@ -17,6 +18,7 @@ module Tina4
17
18
  close: [],
18
19
  error: []
19
20
  }
21
+ @rooms = {} # room_name => Set of conn_ids
20
22
  end
21
23
 
22
24
  def on(event, &block)
@@ -69,6 +71,7 @@ module Tina4
69
71
  emit(:error, connection, e)
70
72
  ensure
71
73
  @connections.delete(conn_id)
74
+ remove_from_all_rooms(conn_id)
72
75
  emit(:close, connection)
73
76
  socket.close rescue nil
74
77
  end
@@ -83,15 +86,46 @@ module Tina4
83
86
  end
84
87
  end
85
88
 
89
+ # ── Rooms ──────────────────────────────────────────────────
90
+
91
+ def join_room_for(conn_id, room_name)
92
+ @rooms[room_name] ||= Set.new
93
+ @rooms[room_name].add(conn_id)
94
+ end
95
+
96
+ def leave_room_for(conn_id, room_name)
97
+ @rooms[room_name]&.delete(conn_id)
98
+ end
99
+
100
+ def room_count(room_name)
101
+ (@rooms[room_name] || Set.new).size
102
+ end
103
+
104
+ def get_room_connections(room_name)
105
+ ids = @rooms[room_name] || Set.new
106
+ ids.filter_map { |id| @connections[id] }
107
+ end
108
+
109
+ def broadcast_to_room(room_name, message, exclude: nil)
110
+ (get_room_connections(room_name)).each do |conn|
111
+ next if exclude && conn.id == exclude
112
+ conn.send_text(message)
113
+ end
114
+ end
115
+
86
116
  private
87
117
 
88
118
  def emit(event, *args)
89
119
  @handlers[event]&.each { |h| h.call(*args) }
90
120
  end
121
+
122
+ def remove_from_all_rooms(conn_id)
123
+ @rooms.each_value { |members| members.delete(conn_id) }
124
+ end
91
125
  end
92
126
 
93
127
  class WebSocketConnection
94
- attr_reader :id
128
+ attr_reader :id, :rooms
95
129
  attr_accessor :params, :path
96
130
 
97
131
  def initialize(id, socket, ws_server: nil, path: "/")
@@ -100,6 +134,24 @@ module Tina4
100
134
  @params = {}
101
135
  @ws_server = ws_server
102
136
  @path = path
137
+ @rooms = Set.new
138
+ end
139
+
140
+ def join_room(room_name)
141
+ @rooms.add(room_name)
142
+ @ws_server&.join_room_for(@id, room_name)
143
+ end
144
+
145
+ def leave_room(room_name)
146
+ @rooms.delete(room_name)
147
+ @ws_server&.leave_room_for(@id, room_name)
148
+ end
149
+
150
+ def broadcast_to_room(room_name, message, exclude_self: false)
151
+ return unless @ws_server
152
+
153
+ exclude = exclude_self ? @id : nil
154
+ @ws_server.broadcast_to_room(room_name, message, exclude: exclude)
103
155
  end
104
156
 
105
157
  # Broadcast a message to all other connections on the same path
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.75
4
+ version: 3.10.83
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-07 00:00:00.000000000 Z
11
+ date: 2026-04-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -324,6 +324,7 @@ files:
324
324
  - lib/tina4/graphql.rb
325
325
  - lib/tina4/health.rb
326
326
  - lib/tina4/html_element.rb
327
+ - lib/tina4/job.rb
327
328
  - lib/tina4/localization.rb
328
329
  - lib/tina4/log.rb
329
330
  - lib/tina4/mcp.rb