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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8be724e92f1d0078e9677ae08fa89545dcacf6bc1145ba6db2b86e86da307b3f
4
- data.tar.gz: ba5369ac9f262e7991af7433291559715ce92e18fcbd0c9a40a641683bd8b607
3
+ metadata.gz: 85d4f3e7d47f44f07e2ab8780a19dd082dcc05e0ba7551e3e8df4b486faa151e
4
+ data.tar.gz: d8662f6770852844ff213be6f1d57329bec5cc243f2d02f2690a04f248c11bf3
5
5
  SHA512:
6
- metadata.gz: 32d9308387bd2c508f6bf504c7af27252c31280f6cc53e7fc1da14891f6a0c59497fa232cff6517c12d52925bf6e58601cd43206fd12f5928317465a8641559d
7
- data.tar.gz: 53644965ad58260075a26c35cc6f8d2cc1044e168077e6145ca21cd44cf0d797232dd0aba3e895eaffa5b6b33abe9f601e79dd5f465499dae1c205608751a532
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 unless use_hmac?
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
- decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
117
- decoded[0]
116
+ JWT.decode(token, public_key, true, algorithm: "RS256")
117
+ true
118
118
  end
119
119
  rescue JWT::ExpiredSignature
120
- nil
120
+ false
121
121
  rescue JWT::DecodeError
122
- nil
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
- payload = valid_token(token)
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
- payload = valid_token(token)
231
- if payload
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
- !valid_token(Regexp.last_match(1)).nil?
290
+ valid_token(Regexp.last_match(1))
293
291
  else
294
292
  false
295
293
  end
@@ -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
@@ -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
- payload = Tina4::Auth.valid_token(bearer_token)
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
- payload = Tina4::Auth.valid_token(token.to_s)
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
@@ -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
- 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) # -> 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 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).
@@ -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
- def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0)
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
- self
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
- def load(sql, params = [], include: nil)
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.find(fk_value)
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.find(fk_value)
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 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.
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 = 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,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::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"]
@@ -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
- payload = Tina4::Auth.valid_token(token)
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"] = 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
- "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.76"
4
+ VERSION = "3.10.84"
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.76
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-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