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 +4 -4
- data/lib/tina4/auth.rb +1 -3
- data/lib/tina4/auto_crud.rb +3 -3
- data/lib/tina4/dev_admin.rb +5 -9
- data/lib/tina4/graphql.rb +4 -4
- data/lib/tina4/job.rb +76 -0
- data/lib/tina4/migration.rb +4 -1
- data/lib/tina4/orm.rb +80 -31
- data/lib/tina4/queue.rb +34 -75
- data/lib/tina4/queue_backends/kafka_backend.rb +2 -2
- data/lib/tina4/queue_backends/lite_backend.rb +53 -2
- data/lib/tina4/queue_backends/mongo_backend.rb +2 -2
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +53 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7f1721eaefd19400dedcfdb298a6af42772aa824b9663a17d79bced2169161b
|
|
4
|
+
data.tar.gz: a712e6a50ea1ece57a4e4bc9e0e49c49dcb0f5e5c56471c1da26bd4f65c42d79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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)
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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(
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
85
|
+
Tina4::Job.new(
|
|
86
86
|
topic: doc["topic"],
|
|
87
87
|
payload: doc["payload"],
|
|
88
88
|
id: doc["_id"]
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|