tina4ruby 3.10.76 → 3.10.84
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 +14 -16
- 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/middleware.rb +3 -5
- data/lib/tina4/migration.rb +21 -1
- data/lib/tina4/orm.rb +125 -53
- data/lib/tina4/queue.rb +38 -79
- data/lib/tina4/queue_backends/kafka_backend.rb +2 -2
- data/lib/tina4/queue_backends/lite_backend.rb +57 -2
- data/lib/tina4/queue_backends/mongo_backend.rb +2 -2
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +1 -1
- data/lib/tina4/rack_app.rb +2 -3
- data/lib/tina4/session.rb +5 -4
- 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: 85d4f3e7d47f44f07e2ab8780a19dd082dcc05e0ba7551e3e8df4b486faa151e
|
|
4
|
+
data.tar.gz: d8662f6770852844ff213be6f1d57329bec5cc243f2d02f2690a04f248c11bf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3955df89a811694cbadb74f30c8f0cebed30e9626b735a4cc6ad172c08acaf2e0b33458593c3700416b11a9b453e76d604a0a34a09b2fff46ec0610d0f58043f
|
|
7
|
+
data.tar.gz: '0103297c9fe0bcde8be0d82b5c71a9fef458f467c16d9ee654c5d806ae4ea82bff69430ca438da5cee7d12c7d33c721bd0bc6e819e06bd8f6de7e85ab0e1141c'
|
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) ──────────────────────
|
|
@@ -107,19 +107,19 @@ module Tina4
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
def valid_token(token)
|
|
110
|
+
def valid_token(token) # -> bool
|
|
111
111
|
if use_hmac?
|
|
112
|
-
hmac_decode(token, hmac_secret)
|
|
112
|
+
!hmac_decode(token, hmac_secret).nil?
|
|
113
113
|
else
|
|
114
114
|
ensure_keys
|
|
115
115
|
require "jwt"
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
JWT.decode(token, public_key, true, algorithm: "RS256")
|
|
117
|
+
true
|
|
118
118
|
end
|
|
119
119
|
rescue JWT::ExpiredSignature
|
|
120
|
-
|
|
120
|
+
false
|
|
121
121
|
rescue JWT::DecodeError
|
|
122
|
-
|
|
122
|
+
false
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
def valid_token_detail(token)
|
|
@@ -174,9 +174,10 @@ module Tina4
|
|
|
174
174
|
end
|
|
175
175
|
|
|
176
176
|
def refresh_token(token, expires_in: 60)
|
|
177
|
-
|
|
178
|
-
return nil unless payload
|
|
177
|
+
return nil unless valid_token(token)
|
|
179
178
|
|
|
179
|
+
payload = get_payload(token)
|
|
180
|
+
return nil unless payload
|
|
180
181
|
payload = payload.reject { |k, _| %w[iat exp nbf].include?(k) }
|
|
181
182
|
get_token(payload, expires_in: expires_in)
|
|
182
183
|
end
|
|
@@ -193,7 +194,7 @@ module Tina4
|
|
|
193
194
|
return { "api_key" => true }
|
|
194
195
|
end
|
|
195
196
|
|
|
196
|
-
valid_token(token)
|
|
197
|
+
valid_token(token) ? get_payload(token) : nil
|
|
197
198
|
end
|
|
198
199
|
|
|
199
200
|
def validate_api_key(provided, expected: nil)
|
|
@@ -227,9 +228,8 @@ module Tina4
|
|
|
227
228
|
return true
|
|
228
229
|
end
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
env["tina4.auth"] = payload
|
|
231
|
+
if valid_token(token)
|
|
232
|
+
env["tina4.auth"] = get_payload(token)
|
|
233
233
|
true
|
|
234
234
|
else
|
|
235
235
|
false
|
|
@@ -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)
|
|
@@ -289,7 +287,7 @@ module Tina4
|
|
|
289
287
|
return true if auth_header.empty?
|
|
290
288
|
|
|
291
289
|
if auth_header =~ /\ABearer\s+(.+)\z/i
|
|
292
|
-
|
|
290
|
+
valid_token(Regexp.last_match(1))
|
|
293
291
|
else
|
|
294
292
|
false
|
|
295
293
|
end
|
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/middleware.rb
CHANGED
|
@@ -307,8 +307,7 @@ module Tina4
|
|
|
307
307
|
if auth_header.start_with?("Bearer ")
|
|
308
308
|
bearer_token = auth_header[7..].strip
|
|
309
309
|
unless bearer_token.empty?
|
|
310
|
-
|
|
311
|
-
return [request, response] if payload
|
|
310
|
+
return [request, response] if Tina4::Auth.valid_token(bearer_token)
|
|
312
311
|
end
|
|
313
312
|
end
|
|
314
313
|
|
|
@@ -344,14 +343,13 @@ module Tina4
|
|
|
344
343
|
end
|
|
345
344
|
|
|
346
345
|
# Validate the token
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if payload.nil?
|
|
346
|
+
unless Tina4::Auth.valid_token(token.to_s)
|
|
350
347
|
response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
|
|
351
348
|
return [request, response]
|
|
352
349
|
end
|
|
353
350
|
|
|
354
351
|
# Session binding — if token has session_id, verify it matches
|
|
352
|
+
payload = Tina4::Auth.get_payload(token.to_s) || {}
|
|
355
353
|
token_session_id = payload["session_id"]
|
|
356
354
|
if token_session_id
|
|
357
355
|
current_session_id = nil
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -87,6 +87,23 @@ module Tina4
|
|
|
87
87
|
filepath
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Get list of applied migration records (public alias for completed_migrations)
|
|
91
|
+
def get_applied
|
|
92
|
+
completed_migrations
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get list of pending migration filenames (public alias for pending_migrations)
|
|
96
|
+
def get_pending
|
|
97
|
+
pending_migrations.map { |f| File.basename(f) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get all migration files on disk, excluding .down files
|
|
101
|
+
def get_files
|
|
102
|
+
migration_files = Dir.glob(File.join(@migrations_dir, "*.sql")).reject { |f| f.end_with?(".down.sql") }
|
|
103
|
+
migration_files += Dir.glob(File.join(@migrations_dir, "*.rb"))
|
|
104
|
+
migration_files.map { |f| File.basename(f) }.sort
|
|
105
|
+
end
|
|
106
|
+
|
|
90
107
|
private
|
|
91
108
|
|
|
92
109
|
# Resolve migrations directory: prefer src/migrations, fall back to migrations/
|
|
@@ -288,7 +305,10 @@ module Tina4
|
|
|
288
305
|
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
289
306
|
next
|
|
290
307
|
end
|
|
291
|
-
@db.execute(stmt)
|
|
308
|
+
result = @db.execute(stmt)
|
|
309
|
+
if result == false
|
|
310
|
+
raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
|
|
311
|
+
end
|
|
292
312
|
end
|
|
293
313
|
end
|
|
294
314
|
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -61,13 +61,25 @@ 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 ||= {}
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# has_one :profile, class_name: "Profile", foreign_key: "user_id"
|
|
70
|
-
def has_one(name, class_name: nil, foreign_key: nil)
|
|
82
|
+
def has_one(name, class_name: nil, foreign_key: nil) # -> nil
|
|
71
83
|
relationship_definitions[name] = {
|
|
72
84
|
type: :has_one,
|
|
73
85
|
class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
|
|
@@ -80,7 +92,7 @@ module Tina4
|
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
# has_many :posts, class_name: "Post", foreign_key: "user_id"
|
|
83
|
-
def has_many(name, class_name: nil, foreign_key: nil)
|
|
95
|
+
def has_many(name, class_name: nil, foreign_key: nil) # -> nil
|
|
84
96
|
relationship_definitions[name] = {
|
|
85
97
|
type: :has_many,
|
|
86
98
|
class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
|
|
@@ -93,7 +105,7 @@ module Tina4
|
|
|
93
105
|
end
|
|
94
106
|
|
|
95
107
|
# belongs_to :user, class_name: "User", foreign_key: "user_id"
|
|
96
|
-
def belongs_to(name, class_name: nil, foreign_key: nil)
|
|
108
|
+
def belongs_to(name, class_name: nil, foreign_key: nil) # -> nil
|
|
97
109
|
relationship_definitions[name] = {
|
|
98
110
|
type: :belongs_to,
|
|
99
111
|
class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
|
|
@@ -111,31 +123,46 @@ module Tina4
|
|
|
111
123
|
# results = User.query.where("active = ?", [1]).order_by("name").get
|
|
112
124
|
#
|
|
113
125
|
# @return [Tina4::QueryBuilder]
|
|
114
|
-
def query
|
|
126
|
+
def query # -> QueryBuilder
|
|
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) # -> list[Self]
|
|
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).
|
|
@@ -216,7 +243,7 @@ module Tina4
|
|
|
216
243
|
end
|
|
217
244
|
end
|
|
218
245
|
|
|
219
|
-
def where(conditions, params = [], include: nil)
|
|
246
|
+
def where(conditions, params = [], include: nil) # -> list[Self]
|
|
220
247
|
sql = "SELECT * FROM #{table_name}"
|
|
221
248
|
if soft_delete
|
|
222
249
|
sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
|
|
@@ -229,7 +256,7 @@ module Tina4
|
|
|
229
256
|
instances
|
|
230
257
|
end
|
|
231
258
|
|
|
232
|
-
def all(limit: nil, offset: nil, order_by: nil, include: nil)
|
|
259
|
+
def all(limit: nil, offset: nil, order_by: nil, include: nil) # -> list[Self]
|
|
233
260
|
sql = "SELECT * FROM #{table_name}"
|
|
234
261
|
if soft_delete
|
|
235
262
|
sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
|
|
@@ -241,19 +268,19 @@ module Tina4
|
|
|
241
268
|
instances
|
|
242
269
|
end
|
|
243
270
|
|
|
244
|
-
def select(sql, params = [], limit: nil, offset: nil, include: nil)
|
|
271
|
+
def select(sql, params = [], limit: nil, offset: nil, include: nil) # -> list[Self]
|
|
245
272
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
246
273
|
instances = results.map { |row| from_hash(row) }
|
|
247
274
|
eager_load(instances, include) if include
|
|
248
275
|
instances
|
|
249
276
|
end
|
|
250
277
|
|
|
251
|
-
def select_one(sql, params = [], include: nil)
|
|
278
|
+
def select_one(sql, params = [], include: nil) # -> Self | nil
|
|
252
279
|
results = select(sql, params, limit: 1, include: include)
|
|
253
280
|
results.first
|
|
254
281
|
end
|
|
255
282
|
|
|
256
|
-
def count(conditions = nil, params = [])
|
|
283
|
+
def count(conditions = nil, params = []) # -> int
|
|
257
284
|
sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
|
|
258
285
|
where_parts = []
|
|
259
286
|
if soft_delete
|
|
@@ -265,25 +292,48 @@ module Tina4
|
|
|
265
292
|
result[:cnt].to_i
|
|
266
293
|
end
|
|
267
294
|
|
|
268
|
-
def create(attributes = {})
|
|
295
|
+
def create(attributes = {}) # -> Self
|
|
269
296
|
instance = new(attributes)
|
|
270
297
|
instance.save
|
|
271
298
|
instance
|
|
272
299
|
end
|
|
273
300
|
|
|
274
|
-
def find_or_fail(id)
|
|
301
|
+
def find_or_fail(id) # -> Self
|
|
275
302
|
result = find(id)
|
|
276
303
|
raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
|
|
277
304
|
result
|
|
278
305
|
end
|
|
279
306
|
|
|
280
|
-
|
|
307
|
+
# Return true if a record with the given primary key exists.
|
|
308
|
+
def exists(pk_value) # -> bool
|
|
309
|
+
find(pk_value) != nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# SQL query with in-memory result caching.
|
|
313
|
+
# Results are cached by (class, sql, params, limit, offset) for +ttl+ seconds.
|
|
314
|
+
def cached(sql, params = [], ttl: 60, limit: 20, offset: 0, include: nil) # -> list[Self]
|
|
315
|
+
@_query_cache ||= Tina4::QueryCache.new(default_ttl: ttl, max_size: 500)
|
|
316
|
+
cache_key = Tina4::QueryCache.query_key("#{name}:#{sql}", params + [limit, offset])
|
|
317
|
+
hit = @_query_cache.get(cache_key)
|
|
318
|
+
return hit unless hit.nil?
|
|
319
|
+
|
|
320
|
+
results = select(sql, params, limit: limit, offset: offset, include: include)
|
|
321
|
+
@_query_cache.set(cache_key, results, ttl: ttl, tags: [name])
|
|
322
|
+
results
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Clear all cached query results for this model.
|
|
326
|
+
def clear_cache # -> nil
|
|
327
|
+
@_query_cache&.clear_tag(name)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0) # -> list[Self]
|
|
281
331
|
sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
|
|
282
332
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
283
333
|
results.map { |row| from_hash(row) }
|
|
284
334
|
end
|
|
285
335
|
|
|
286
|
-
def create_table
|
|
336
|
+
def create_table # -> bool
|
|
287
337
|
return true if db.table_exists?(table_name)
|
|
288
338
|
|
|
289
339
|
type_map = {
|
|
@@ -324,7 +374,7 @@ module Tina4
|
|
|
324
374
|
true
|
|
325
375
|
end
|
|
326
376
|
|
|
327
|
-
def scope(name, filter_sql, params = [])
|
|
377
|
+
def scope(name, filter_sql, params = []) # -> nil
|
|
328
378
|
define_singleton_method(name) do |limit: 20, offset: 0|
|
|
329
379
|
where(filter_sql, params)
|
|
330
380
|
end
|
|
@@ -343,6 +393,16 @@ module Tina4
|
|
|
343
393
|
instance
|
|
344
394
|
end
|
|
345
395
|
|
|
396
|
+
# Find a single record by primary key. Returns instance or nil.
|
|
397
|
+
def find_by_id(id) # -> Self | nil
|
|
398
|
+
pk = primary_key_field || :id
|
|
399
|
+
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
400
|
+
if soft_delete
|
|
401
|
+
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
402
|
+
end
|
|
403
|
+
select_one(sql, [id])
|
|
404
|
+
end
|
|
405
|
+
|
|
346
406
|
private
|
|
347
407
|
|
|
348
408
|
def auto_discover_db
|
|
@@ -352,15 +412,6 @@ module Tina4
|
|
|
352
412
|
Tina4.database
|
|
353
413
|
end
|
|
354
414
|
|
|
355
|
-
def find_by_id(id)
|
|
356
|
-
pk = primary_key_field || :id
|
|
357
|
-
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
358
|
-
if soft_delete
|
|
359
|
-
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
360
|
-
end
|
|
361
|
-
select_one(sql, [id])
|
|
362
|
-
end
|
|
363
|
-
|
|
364
415
|
def find_by_filter(filter)
|
|
365
416
|
where_parts = filter.keys.map { |k| "#{k} = ?" }
|
|
366
417
|
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
@@ -388,7 +439,7 @@ module Tina4
|
|
|
388
439
|
end
|
|
389
440
|
end
|
|
390
441
|
|
|
391
|
-
def save
|
|
442
|
+
def save # -> Self | bool
|
|
392
443
|
@errors = []
|
|
393
444
|
@relationship_cache = {} # Clear relationship cache on save
|
|
394
445
|
validate_fields
|
|
@@ -414,13 +465,13 @@ module Tina4
|
|
|
414
465
|
@persisted = true
|
|
415
466
|
end
|
|
416
467
|
end
|
|
417
|
-
|
|
468
|
+
true
|
|
418
469
|
rescue => e
|
|
419
470
|
@errors << e.message
|
|
420
471
|
false
|
|
421
472
|
end
|
|
422
473
|
|
|
423
|
-
def delete
|
|
474
|
+
def delete # -> bool
|
|
424
475
|
pk = self.class.primary_key_field || :id
|
|
425
476
|
pk_value = __send__(pk)
|
|
426
477
|
return false unless pk_value
|
|
@@ -440,7 +491,7 @@ module Tina4
|
|
|
440
491
|
true
|
|
441
492
|
end
|
|
442
493
|
|
|
443
|
-
def force_delete
|
|
494
|
+
def force_delete # -> bool
|
|
444
495
|
pk = self.class.primary_key_field || :id
|
|
445
496
|
pk_value = __send__(pk)
|
|
446
497
|
raise "Cannot delete: no primary key value" unless pk_value
|
|
@@ -452,7 +503,7 @@ module Tina4
|
|
|
452
503
|
true
|
|
453
504
|
end
|
|
454
505
|
|
|
455
|
-
def restore
|
|
506
|
+
def restore # -> bool
|
|
456
507
|
raise "Model does not support soft delete" unless self.class.soft_delete
|
|
457
508
|
|
|
458
509
|
pk = self.class.primary_key_field || :id
|
|
@@ -470,7 +521,7 @@ module Tina4
|
|
|
470
521
|
true
|
|
471
522
|
end
|
|
472
523
|
|
|
473
|
-
def validate
|
|
524
|
+
def validate # -> list[str]
|
|
474
525
|
errors = []
|
|
475
526
|
self.class.field_definitions.each do |name, opts|
|
|
476
527
|
value = __send__(name)
|
|
@@ -483,8 +534,29 @@ module Tina4
|
|
|
483
534
|
|
|
484
535
|
# Load a record into this instance via select_one.
|
|
485
536
|
# Returns true if found and loaded, false otherwise.
|
|
486
|
-
|
|
537
|
+
# Load a record into this instance.
|
|
538
|
+
#
|
|
539
|
+
# Usage:
|
|
540
|
+
# orm.id = 1; orm.load — uses PK already set
|
|
541
|
+
# orm.load("id = ?", [1]) — filter with params
|
|
542
|
+
# orm.load("id = 1") — filter string
|
|
543
|
+
#
|
|
544
|
+
# Returns true if a record was found, false otherwise.
|
|
545
|
+
def load(filter = nil, params = [], include: nil) # -> bool
|
|
487
546
|
@relationship_cache = {}
|
|
547
|
+
table = self.class.table_name
|
|
548
|
+
|
|
549
|
+
if filter.nil?
|
|
550
|
+
pk = self.class.primary_key
|
|
551
|
+
pk_col = self.class.field_mapping[pk.to_s] || pk
|
|
552
|
+
pk_value = __send__(pk)
|
|
553
|
+
return false if pk_value.nil?
|
|
554
|
+
sql = "SELECT * FROM #{table} WHERE #{pk_col} = ?"
|
|
555
|
+
params = [pk_value]
|
|
556
|
+
else
|
|
557
|
+
sql = "SELECT * FROM #{table} WHERE #{filter}"
|
|
558
|
+
end
|
|
559
|
+
|
|
488
560
|
result = self.class.select_one(sql, params, include: include)
|
|
489
561
|
return false unless result
|
|
490
562
|
|
|
@@ -508,7 +580,7 @@ module Tina4
|
|
|
508
580
|
|
|
509
581
|
# Convert to hash using Ruby attribute names.
|
|
510
582
|
# Optionally include relationships via the include keyword.
|
|
511
|
-
def to_h(include: nil)
|
|
583
|
+
def to_h(include: nil) # -> dict
|
|
512
584
|
hash = {}
|
|
513
585
|
self.class.field_definitions.each_key do |name|
|
|
514
586
|
hash[name] = __send__(name)
|
|
@@ -545,13 +617,13 @@ module Tina4
|
|
|
545
617
|
alias to_assoc to_h
|
|
546
618
|
alias to_object to_h
|
|
547
619
|
|
|
548
|
-
def to_array
|
|
620
|
+
def to_array # -> list
|
|
549
621
|
to_h.values
|
|
550
622
|
end
|
|
551
623
|
|
|
552
624
|
alias to_list to_array
|
|
553
625
|
|
|
554
|
-
def to_json(include: nil, **_args)
|
|
626
|
+
def to_json(include: nil, **_args) # -> str
|
|
555
627
|
JSON.generate(to_h(include: include))
|
|
556
628
|
end
|
|
557
629
|
|
|
@@ -634,7 +706,7 @@ module Tina4
|
|
|
634
706
|
fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
|
|
635
707
|
return nil unless fk_value
|
|
636
708
|
|
|
637
|
-
@relationship_cache[name] = klass.
|
|
709
|
+
@relationship_cache[name] = klass.find_by_id(fk_value)
|
|
638
710
|
end
|
|
639
711
|
|
|
640
712
|
public
|
|
@@ -671,7 +743,7 @@ module Tina4
|
|
|
671
743
|
fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
|
|
672
744
|
return nil unless fk_value
|
|
673
745
|
|
|
674
|
-
related_class.
|
|
746
|
+
related_class.find_by_id(fk_value)
|
|
675
747
|
end
|
|
676
748
|
|
|
677
749
|
# 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,43 +25,61 @@ 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
|
|
105
|
-
def pop
|
|
38
|
+
# Pop the next available job. Returns Job or nil.
|
|
39
|
+
def pop # -> Job|None
|
|
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 # -> int
|
|
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 # -> list[dict]
|
|
51
|
+
return [] unless @backend.respond_to?(:failed)
|
|
52
|
+
@backend.failed(@topic, max_retries: @max_retries)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Retry failed jobs on this queue's topic. Returns true if re-queued.
|
|
56
|
+
def retry(delay_seconds = 0) # -> bool
|
|
57
|
+
return false unless @backend.respond_to?(:retry_job)
|
|
58
|
+
@backend.retry_job(@topic, delay_seconds: delay_seconds)
|
|
59
|
+
end
|
|
60
|
+
|
|
109
61
|
# Get dead letter jobs — messages that exceeded max retries.
|
|
110
|
-
def dead_letters
|
|
62
|
+
def dead_letters # -> list[dict]
|
|
111
63
|
return [] unless @backend.respond_to?(:dead_letters)
|
|
112
64
|
@backend.dead_letters(@topic, max_retries: @max_retries)
|
|
113
65
|
end
|
|
114
66
|
|
|
115
67
|
# Delete messages by status (completed, failed, dead).
|
|
116
|
-
def purge(status)
|
|
68
|
+
def purge(status) # -> int
|
|
117
69
|
return 0 unless @backend.respond_to?(:purge)
|
|
118
70
|
@backend.purge(@topic, status)
|
|
119
71
|
end
|
|
120
72
|
|
|
121
73
|
# Re-queue failed messages (under max_retries) back to pending.
|
|
122
74
|
# Returns the number of jobs re-queued.
|
|
123
|
-
def retry_failed
|
|
75
|
+
def retry_failed # -> int
|
|
124
76
|
return 0 unless @backend.respond_to?(:retry_failed)
|
|
125
77
|
@backend.retry_failed(@topic, max_retries: @max_retries)
|
|
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,61 @@ 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 all dead letter jobs for this topic. Returns true if any were re-queued.
|
|
222
|
+
def retry_job(topic, delay_seconds: 0)
|
|
223
|
+
return false unless Dir.exist?(@dead_letter_dir)
|
|
224
|
+
|
|
225
|
+
available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
|
|
226
|
+
count = 0
|
|
227
|
+
|
|
228
|
+
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
229
|
+
data = JSON.parse(File.read(file))
|
|
230
|
+
next unless data["topic"] == topic.to_s
|
|
231
|
+
|
|
232
|
+
msg = Tina4::Job.new(
|
|
233
|
+
topic: data["topic"],
|
|
234
|
+
payload: data["payload"],
|
|
235
|
+
id: data["id"],
|
|
236
|
+
attempts: (data["attempts"] || 0) + 1,
|
|
237
|
+
available_at: available_at
|
|
238
|
+
)
|
|
239
|
+
enqueue(msg)
|
|
240
|
+
File.delete(file)
|
|
241
|
+
count += 1
|
|
242
|
+
rescue JSON::ParserError
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
count > 0
|
|
247
|
+
end
|
|
248
|
+
|
|
194
249
|
private
|
|
195
250
|
|
|
196
251
|
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/rack_app.rb
CHANGED
|
@@ -176,11 +176,10 @@ module Tina4
|
|
|
176
176
|
if api_key && !api_key.empty? && token == api_key
|
|
177
177
|
env["tina4.auth_payload"] = { "api_key" => true }
|
|
178
178
|
elsif token
|
|
179
|
-
|
|
180
|
-
unless payload
|
|
179
|
+
unless Tina4::Auth.valid_token(token)
|
|
181
180
|
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
182
181
|
end
|
|
183
|
-
env["tina4.auth_payload"] =
|
|
182
|
+
env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
|
|
184
183
|
else
|
|
185
184
|
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
186
185
|
end
|
data/lib/tina4/session.rb
CHANGED
|
@@ -114,9 +114,10 @@ module Tina4
|
|
|
114
114
|
@handler.gc(max_age) if @handler.respond_to?(:gc)
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def cookie_header
|
|
117
|
+
def cookie_header(cookie_name = nil)
|
|
118
|
+
name = cookie_name || @options[:cookie_name]
|
|
118
119
|
samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
|
|
119
|
-
"#{
|
|
120
|
+
"#{name}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
private
|
|
@@ -223,9 +224,9 @@ module Tina4
|
|
|
223
224
|
@session.gc(max_age)
|
|
224
225
|
end
|
|
225
226
|
|
|
226
|
-
def cookie_header
|
|
227
|
+
def cookie_header(cookie_name = nil)
|
|
227
228
|
ensure_loaded
|
|
228
|
-
@session.cookie_header
|
|
229
|
+
@session.cookie_header(cookie_name)
|
|
229
230
|
end
|
|
230
231
|
|
|
231
232
|
def to_hash
|
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.84
|
|
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
|