tina4ruby 3.10.90 → 3.10.91
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/api.rb +50 -13
- data/lib/tina4/auth.rb +11 -3
- data/lib/tina4/auto_crud.rb +5 -5
- data/lib/tina4/cache.rb +154 -0
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/container.rb +6 -6
- data/lib/tina4/crud.rb +3 -3
- data/lib/tina4/database.rb +90 -4
- data/lib/tina4/drivers/sqlite_driver.rb +4 -0
- data/lib/tina4/frond.rb +8 -0
- data/lib/tina4/graphql.rb +27 -24
- data/lib/tina4/health.rb +1 -1
- data/lib/tina4/job.rb +8 -4
- data/lib/tina4/localization.rb +21 -0
- data/lib/tina4/middleware.rb +18 -6
- data/lib/tina4/migration.rb +76 -25
- data/lib/tina4/orm.rb +23 -4
- data/lib/tina4/queue.rb +96 -21
- data/lib/tina4/queue_backends/lite_backend.rb +42 -1
- data/lib/tina4/rack_app.rb +3 -3
- data/lib/tina4/router.rb +34 -15
- data/lib/tina4/seeder.rb +33 -1
- data/lib/tina4/session.rb +59 -5
- data/lib/tina4/sql_translation.rb +1 -138
- data/lib/tina4/test_client.rb +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +65 -39
- data/lib/tina4.rb +15 -14
- metadata +3 -2
data/lib/tina4/graphql.rb
CHANGED
|
@@ -68,23 +68,36 @@ module Tina4
|
|
|
68
68
|
register_scalars
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
# add_type(name, fields) — parity with PHP/Python/Node
|
|
72
|
+
# add_type(type_object) — legacy Ruby form (type_object responds to .name)
|
|
73
|
+
def add_type(name_or_type, fields = nil)
|
|
74
|
+
if fields
|
|
75
|
+
# New form: add_type("User", { "id" => "ID", "name" => "String" })
|
|
76
|
+
@types[name_or_type] = fields
|
|
77
|
+
else
|
|
78
|
+
# Legacy form: add_type(GraphQLType.new(...))
|
|
79
|
+
@types[name_or_type.name] = name_or_type
|
|
80
|
+
end
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
def get_type(name)
|
|
76
84
|
@types[name]
|
|
77
85
|
end
|
|
78
86
|
|
|
79
|
-
# Register a query field
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
# Register a query field.
|
|
88
|
+
# Cross-framework form: add_query(name, args, return_type, resolver)
|
|
89
|
+
# Block form also accepted: add_query(name, args, return_type) { |root, args, ctx| ... }
|
|
90
|
+
def add_query(name, args = {}, return_type = nil, resolver = nil, &block)
|
|
91
|
+
resolve = resolver || block
|
|
92
|
+
@queries[name] = { type: return_type, args: args, resolve: resolve }
|
|
83
93
|
end
|
|
84
94
|
|
|
85
|
-
# Register a mutation field
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
# Register a mutation field.
|
|
96
|
+
# Cross-framework form: add_mutation(name, args, return_type, resolver)
|
|
97
|
+
# Block form also accepted: add_mutation(name, args, return_type) { |root, args, ctx| ... }
|
|
98
|
+
def add_mutation(name, args = {}, return_type = nil, resolver = nil, &block)
|
|
99
|
+
resolve = resolver || block
|
|
100
|
+
@mutations[name] = { type: return_type, args: args, resolve: resolve }
|
|
88
101
|
end
|
|
89
102
|
|
|
90
103
|
# ── ORM Auto-Schema ──────────────────────────────────────────────────
|
|
@@ -127,17 +140,13 @@ module Tina4
|
|
|
127
140
|
# ── Queries ──
|
|
128
141
|
|
|
129
142
|
# Single record: user(id: ID!): User
|
|
130
|
-
add_query(table_lower, type: type_name,
|
|
131
|
-
args: { pk_field => { type: "ID!" } },
|
|
132
|
-
description: "Fetch a single #{model_name} by #{pk_field}") do |_root, args, _ctx|
|
|
143
|
+
add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
|
|
133
144
|
record = klass.find_by_id(args[pk_field])
|
|
134
145
|
record&.to_hash
|
|
135
146
|
end
|
|
136
147
|
|
|
137
148
|
# List: users(limit: Int, offset: Int): [User]
|
|
138
|
-
add_query(plural, type: "[#{type_name}]",
|
|
139
|
-
args: { "limit" => { type: "Int" }, "offset" => { type: "Int" } },
|
|
140
|
-
description: "Fetch a list of #{model_name} records") do |_root, args, _ctx|
|
|
149
|
+
add_query(plural, { "limit" => { type: "Int" }, "offset" => { type: "Int" } }, "[#{type_name}]") do |_root, args, _ctx|
|
|
141
150
|
limit = args["limit"] || 100
|
|
142
151
|
offset = args["offset"] || 0
|
|
143
152
|
result = klass.all(limit: limit, offset: offset)
|
|
@@ -147,17 +156,13 @@ module Tina4
|
|
|
147
156
|
# ── Mutations ──
|
|
148
157
|
|
|
149
158
|
# Create
|
|
150
|
-
add_mutation("create#{model_name}", type: type_name,
|
|
151
|
-
args: { "input" => { type: "#{type_name}Input!" } },
|
|
152
|
-
description: "Create a new #{model_name}") do |_root, args, _ctx|
|
|
159
|
+
add_mutation("create#{model_name}", { "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
|
|
153
160
|
record = klass.create(args["input"] || {})
|
|
154
161
|
record.respond_to?(:to_hash) ? record.to_hash : record
|
|
155
162
|
end
|
|
156
163
|
|
|
157
164
|
# Update
|
|
158
|
-
add_mutation("update#{model_name}", type: type_name,
|
|
159
|
-
args: { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } },
|
|
160
|
-
description: "Update an existing #{model_name}") do |_root, args, _ctx|
|
|
165
|
+
add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
|
|
161
166
|
record = klass.find_by_id(args[pk_field])
|
|
162
167
|
return nil unless record
|
|
163
168
|
(args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
|
|
@@ -166,9 +171,7 @@ module Tina4
|
|
|
166
171
|
end
|
|
167
172
|
|
|
168
173
|
# Delete
|
|
169
|
-
add_mutation("delete#{model_name}", type: "Boolean",
|
|
170
|
-
args: { pk_field => { type: "ID!" } },
|
|
171
|
-
description: "Delete a #{model_name} by #{pk_field}") do |_root, args, _ctx|
|
|
174
|
+
add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
|
|
172
175
|
record = klass.find_by_id(args[pk_field])
|
|
173
176
|
return false unless record
|
|
174
177
|
record.delete
|
data/lib/tina4/health.rb
CHANGED
data/lib/tina4/job.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Tina4
|
|
|
7
7
|
attr_reader :id, :topic, :payload, :created_at, :attempts, :priority, :available_at
|
|
8
8
|
attr_accessor :status
|
|
9
9
|
|
|
10
|
-
def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0)
|
|
10
|
+
def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0, queue: nil)
|
|
11
11
|
@id = id || SecureRandom.uuid
|
|
12
12
|
@topic = topic
|
|
13
13
|
@payload = payload
|
|
@@ -16,15 +16,19 @@ module Tina4
|
|
|
16
16
|
@priority = priority
|
|
17
17
|
@available_at = available_at
|
|
18
18
|
@status = :pending
|
|
19
|
+
@queue = queue
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# Re-queue this message with incremented attempts.
|
|
22
|
-
#
|
|
23
|
-
def retry(
|
|
23
|
+
# Uses the stored queue reference (set at construction time).
|
|
24
|
+
def retry(delay_seconds: 0)
|
|
25
|
+
q = @queue
|
|
26
|
+
raise ArgumentError, "No queue reference — set at construction" unless q
|
|
27
|
+
|
|
24
28
|
@attempts += 1
|
|
25
29
|
@status = :pending
|
|
26
30
|
@available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
|
|
27
|
-
|
|
31
|
+
q.backend.enqueue(self)
|
|
28
32
|
self
|
|
29
33
|
end
|
|
30
34
|
|
data/lib/tina4/localization.rb
CHANGED
|
@@ -64,6 +64,27 @@ module Tina4
|
|
|
64
64
|
value
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def set_locale(locale)
|
|
68
|
+
self.current_locale = locale.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def get_locale
|
|
72
|
+
current_locale
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def translate(key, params: nil, locale: nil)
|
|
76
|
+
t(key, locale: locale, **(params || {}))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_translations(locale)
|
|
80
|
+
load(Dir.pwd) if translations.empty?
|
|
81
|
+
translations[locale.to_s] || {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_translation(locale, key, value)
|
|
85
|
+
add(locale, key, value)
|
|
86
|
+
end
|
|
87
|
+
|
|
67
88
|
def add(locale, key, value)
|
|
68
89
|
translations[locale.to_s] ||= {}
|
|
69
90
|
keys = key.to_s.split(".")
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Tina4
|
|
|
16
16
|
@global_middleware ||= []
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Parity alias matching Python/PHP/Node orchestrators.
|
|
20
|
+
def get_global
|
|
21
|
+
global_middleware.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
def before(pattern = nil, &block)
|
|
20
25
|
before_handlers << { pattern: pattern, handler: block }
|
|
21
26
|
end
|
|
@@ -37,9 +42,13 @@ module Tina4
|
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
# Run all "before" hooks: block-based handlers, then class-based before_* methods.
|
|
45
|
+
#
|
|
46
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
47
|
+
# middleware classes explicitly.
|
|
48
|
+
#
|
|
40
49
|
# Returns [request, response] on success, or false to halt the request.
|
|
41
|
-
def run_before(request, response)
|
|
42
|
-
# 1. Block-based before handlers (
|
|
50
|
+
def run_before(middleware_classes, request, response)
|
|
51
|
+
# 1. Block-based before handlers (pattern-matched)
|
|
43
52
|
before_handlers.each do |entry|
|
|
44
53
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
45
54
|
result = entry[:handler].call(request, response)
|
|
@@ -47,7 +56,7 @@ module Tina4
|
|
|
47
56
|
end
|
|
48
57
|
|
|
49
58
|
# 2. Class-based middleware: call every before_* method
|
|
50
|
-
|
|
59
|
+
middleware_classes.each do |klass|
|
|
51
60
|
before_methods_for(klass).each do |method_name|
|
|
52
61
|
result = klass.send(method_name, request, response)
|
|
53
62
|
# Support returning [request, response] (Python convention) or false to halt
|
|
@@ -65,15 +74,18 @@ module Tina4
|
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
# Run all "after" hooks: block-based handlers, then class-based after_* methods.
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
#
|
|
78
|
+
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
79
|
+
# middleware classes explicitly.
|
|
80
|
+
def run_after(middleware_classes, request, response)
|
|
81
|
+
# 1. Block-based after handlers (pattern-matched)
|
|
70
82
|
after_handlers.each do |entry|
|
|
71
83
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
72
84
|
entry[:handler].call(request, response)
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
# 2. Class-based middleware: call every after_* method
|
|
76
|
-
|
|
88
|
+
middleware_classes.each do |klass|
|
|
77
89
|
after_methods_for(klass).each do |method_name|
|
|
78
90
|
result = klass.send(method_name, request, response)
|
|
79
91
|
if result.is_a?(Array) && result.length == 2
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -61,30 +61,75 @@ module Tina4
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Create a new migration file
|
|
64
|
-
|
|
64
|
+
#
|
|
65
|
+
# kind="ruby" — creates {timestamp}_{description}.rb with MigrationBase subclass (default)
|
|
66
|
+
# kind="sql" — creates {timestamp}_{description}.sql + .down.sql
|
|
67
|
+
# kind="python" — alias for "ruby" (class-based scaffold for cross-framework parity)
|
|
68
|
+
def create(description, kind = "sql")
|
|
65
69
|
FileUtils.mkdir_p(@migrations_dir)
|
|
66
70
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
created_at = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
72
|
+
safe_name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
73
|
+
|
|
74
|
+
if kind == "ruby" || kind == "python"
|
|
75
|
+
filename = "#{timestamp}_#{safe_name}.rb"
|
|
76
|
+
filepath = File.join(@migrations_dir, filename)
|
|
77
|
+
|
|
78
|
+
File.write(filepath, <<~RUBY)
|
|
79
|
+
# frozen_string_literal: true
|
|
80
|
+
# Migration: #{description}
|
|
81
|
+
# Created: #{created_at}
|
|
82
|
+
|
|
83
|
+
class #{classify(description)} < Tina4::MigrationBase
|
|
84
|
+
def up(db = nil)
|
|
85
|
+
# db.execute("CREATE TABLE ...")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def down(db = nil)
|
|
89
|
+
# db.execute("DROP TABLE IF EXISTS ...")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
RUBY
|
|
69
93
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# Created: #{Time.now}
|
|
94
|
+
Tina4::Log.info("Created migration: #{filename}")
|
|
95
|
+
return filepath
|
|
96
|
+
end
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
# Default: SQL
|
|
99
|
+
up_filename = "#{timestamp}_#{safe_name}.sql"
|
|
100
|
+
down_filename = "#{timestamp}_#{safe_name}.down.sql"
|
|
101
|
+
up_path = File.join(@migrations_dir, up_filename)
|
|
102
|
+
down_path = File.join(@migrations_dir, down_filename)
|
|
79
103
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
RUBY
|
|
104
|
+
File.write(up_path, "-- Migration: #{description}\n-- Created: #{created_at}\n\n")
|
|
105
|
+
File.write(down_path, "-- Rollback: #{description}\n-- Created: #{created_at}\n\n")
|
|
85
106
|
|
|
86
|
-
Tina4::Log.info("Created migration: #{
|
|
87
|
-
|
|
107
|
+
Tina4::Log.info("Created migration: #{up_filename}")
|
|
108
|
+
up_path
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Insert a record into the migration tracking table.
|
|
112
|
+
#
|
|
113
|
+
# @param name [String] Migration filename (e.g. "20240101000000_create_users.sql")
|
|
114
|
+
# @param batch [Integer] Batch number this migration belongs to
|
|
115
|
+
# @param passed [Integer] 1 if successful (default), 0 if failed
|
|
116
|
+
def record_migration(name, batch, passed: 1)
|
|
117
|
+
_record_migration(name, batch, passed: passed)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Delete a migration record from the tracking table by filename.
|
|
121
|
+
#
|
|
122
|
+
# @param name [String] Migration filename to remove
|
|
123
|
+
def remove_migration_record(name)
|
|
124
|
+
_remove_migration_record(name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Create a migration file — static helper for parity with Python/Node.
|
|
128
|
+
# @param description [String] Human-readable migration name
|
|
129
|
+
# @param migrations_dir [String] Directory for migration files (default: 'migrations')
|
|
130
|
+
# @param kind [String] File kind: 'sql' or 'ruby' (default: 'sql')
|
|
131
|
+
def self.create_migration(description, migrations_dir: "migrations", kind: "sql")
|
|
132
|
+
new(nil, migrations_dir: migrations_dir).create(description, kind)
|
|
88
133
|
end
|
|
89
134
|
|
|
90
135
|
# Get list of applied migration records (public alias for completed_migrations)
|
|
@@ -92,6 +137,11 @@ module Tina4
|
|
|
92
137
|
completed_migrations
|
|
93
138
|
end
|
|
94
139
|
|
|
140
|
+
# Alias for get_applied — parity with PHP/Node
|
|
141
|
+
def get_applied_migrations
|
|
142
|
+
get_applied
|
|
143
|
+
end
|
|
144
|
+
|
|
95
145
|
# Get list of pending migration filenames (public alias for pending_migrations)
|
|
96
146
|
def get_pending
|
|
97
147
|
pending_migrations.map { |f| File.basename(f) }
|
|
@@ -119,6 +169,7 @@ module Tina4
|
|
|
119
169
|
end
|
|
120
170
|
|
|
121
171
|
def ensure_tracking_table
|
|
172
|
+
return unless @db
|
|
122
173
|
unless @db.table_exists?(TRACKING_TABLE)
|
|
123
174
|
if firebird?
|
|
124
175
|
# Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
|
|
@@ -201,11 +252,11 @@ module Tina4
|
|
|
201
252
|
else
|
|
202
253
|
execute_sql_file(file)
|
|
203
254
|
end
|
|
204
|
-
|
|
255
|
+
_record_migration(name, batch, passed: 1)
|
|
205
256
|
{ name: name, status: "success" }
|
|
206
257
|
rescue => e
|
|
207
258
|
Tina4::Log.error("Migration failed: #{name} - #{e.message}")
|
|
208
|
-
|
|
259
|
+
_record_migration(name, batch, passed: 0)
|
|
209
260
|
{ name: name, status: "failed", error: e.message }
|
|
210
261
|
end
|
|
211
262
|
end
|
|
@@ -224,7 +275,7 @@ module Tina4
|
|
|
224
275
|
Tina4::Log.warning("No rollback file for: #{name}")
|
|
225
276
|
end
|
|
226
277
|
end
|
|
227
|
-
|
|
278
|
+
_remove_migration_record(name)
|
|
228
279
|
{ name: name, status: "rolled_back" }
|
|
229
280
|
rescue => e
|
|
230
281
|
Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
|
|
@@ -345,7 +396,7 @@ module Tina4
|
|
|
345
396
|
end
|
|
346
397
|
end
|
|
347
398
|
|
|
348
|
-
def
|
|
399
|
+
def _record_migration(name, batch, passed: 1)
|
|
349
400
|
# Extract description from filename (strip numeric prefix and extension)
|
|
350
401
|
stem = File.basename(name, File.extname(name))
|
|
351
402
|
desc = stem.sub(/\A\d+_/, "").tr("_", " ")
|
|
@@ -367,7 +418,7 @@ module Tina4
|
|
|
367
418
|
end
|
|
368
419
|
end
|
|
369
420
|
|
|
370
|
-
def
|
|
421
|
+
def _remove_migration_record(name)
|
|
371
422
|
@db.delete(TRACKING_TABLE, { migration_name: name })
|
|
372
423
|
end
|
|
373
424
|
|
|
@@ -389,11 +440,11 @@ module Tina4
|
|
|
389
440
|
|
|
390
441
|
# Base class for Ruby migrations
|
|
391
442
|
class MigrationBase
|
|
392
|
-
def up(db)
|
|
443
|
+
def up(db = nil)
|
|
393
444
|
raise NotImplementedError, "Implement #up in your migration"
|
|
394
445
|
end
|
|
395
446
|
|
|
396
|
-
def down(db)
|
|
447
|
+
def down(db = nil)
|
|
397
448
|
raise NotImplementedError, "Implement #down in your migration"
|
|
398
449
|
end
|
|
399
450
|
end
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -243,14 +243,14 @@ module Tina4
|
|
|
243
243
|
end
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
-
def where(conditions, params = [], include: nil) # -> list[Self]
|
|
246
|
+
def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
|
|
247
247
|
sql = "SELECT * FROM #{table_name}"
|
|
248
248
|
if soft_delete
|
|
249
249
|
sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
|
|
250
250
|
else
|
|
251
251
|
sql += " WHERE #{conditions}"
|
|
252
252
|
end
|
|
253
|
-
results = db.fetch(sql, params)
|
|
253
|
+
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
254
254
|
instances = results.map { |row| from_hash(row) }
|
|
255
255
|
eager_load(instances, include) if include
|
|
256
256
|
instances
|
|
@@ -394,13 +394,32 @@ module Tina4
|
|
|
394
394
|
end
|
|
395
395
|
|
|
396
396
|
# Find a single record by primary key. Returns instance or nil.
|
|
397
|
-
def find_by_id(id) # -> Self | nil
|
|
397
|
+
def find_by_id(id, include: nil) # -> Self | nil
|
|
398
398
|
pk = primary_key_field || :id
|
|
399
399
|
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
400
400
|
if soft_delete
|
|
401
401
|
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
402
402
|
end
|
|
403
|
-
select_one(sql, [id])
|
|
403
|
+
select_one(sql, [id], include: include)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Clear the relationship cache on all loaded instances (class-level helper).
|
|
407
|
+
# Useful after bulk operations when you want to force relationship re-loads.
|
|
408
|
+
def clear_rel_cache # -> nil
|
|
409
|
+
@_rel_cache = {}
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Return the database connection used by this model.
|
|
414
|
+
def get_db # -> Database
|
|
415
|
+
db
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Map a Ruby property name to its database column name using field_mapping.
|
|
419
|
+
# Returns the column name as a symbol.
|
|
420
|
+
def get_db_column(property) # -> Symbol
|
|
421
|
+
col = field_mapping[property.to_s] || property
|
|
422
|
+
col.to_sym
|
|
404
423
|
end
|
|
405
424
|
|
|
406
425
|
private
|
data/lib/tina4/queue.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Tina4
|
|
|
30
30
|
# delay_seconds: delay before the message becomes available (default 0).
|
|
31
31
|
def push(payload, priority: 0, delay_seconds: 0)
|
|
32
32
|
available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
|
|
33
|
-
message = Job.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, queue: self)
|
|
34
34
|
@backend.enqueue(message)
|
|
35
35
|
message
|
|
36
36
|
end
|
|
@@ -40,6 +40,21 @@ module Tina4
|
|
|
40
40
|
@backend.dequeue(@topic)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Pop up to count jobs at once. Returns a partial batch if fewer available.
|
|
44
|
+
def pop_batch(count)
|
|
45
|
+
if @backend.respond_to?(:dequeue_batch)
|
|
46
|
+
@backend.dequeue_batch(@topic, count)
|
|
47
|
+
else
|
|
48
|
+
jobs = []
|
|
49
|
+
count.times do
|
|
50
|
+
job = @backend.dequeue(@topic)
|
|
51
|
+
break if job.nil?
|
|
52
|
+
jobs << job
|
|
53
|
+
end
|
|
54
|
+
jobs
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
43
58
|
# Clear all pending jobs from this queue's topic. Returns count removed.
|
|
44
59
|
def clear # -> int
|
|
45
60
|
return 0 unless @backend.respond_to?(:clear)
|
|
@@ -52,34 +67,37 @@ module Tina4
|
|
|
52
67
|
@backend.failed(@topic, max_retries: @max_retries)
|
|
53
68
|
end
|
|
54
69
|
|
|
55
|
-
# Retry failed
|
|
56
|
-
|
|
70
|
+
# Retry a specific failed job by ID, or all dead-letter jobs if no id given.
|
|
71
|
+
# Returns true if re-queued.
|
|
72
|
+
def retry(job_id = nil, delay_seconds: 0) # -> bool
|
|
57
73
|
return false unless @backend.respond_to?(:retry_job)
|
|
58
|
-
@backend.retry_job(@topic, delay_seconds: delay_seconds)
|
|
74
|
+
@backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
# Get dead letter jobs — messages that exceeded max retries.
|
|
62
|
-
|
|
78
|
+
# Pass max_retries to override the queue's default.
|
|
79
|
+
def dead_letters(max_retries: nil) # -> list[dict]
|
|
63
80
|
return [] unless @backend.respond_to?(:dead_letters)
|
|
64
|
-
@backend.dead_letters(@topic, max_retries: @max_retries)
|
|
81
|
+
@backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
|
|
65
82
|
end
|
|
66
83
|
|
|
67
84
|
# Delete messages by status (completed, failed, dead).
|
|
68
|
-
def purge(status) # -> int
|
|
85
|
+
def purge(status, max_retries: nil) # -> int
|
|
69
86
|
return 0 unless @backend.respond_to?(:purge)
|
|
70
87
|
@backend.purge(@topic, status)
|
|
71
88
|
end
|
|
72
89
|
|
|
73
90
|
# Re-queue failed messages (under max_retries) back to pending.
|
|
74
91
|
# Returns the number of jobs re-queued.
|
|
75
|
-
def retry_failed # -> int
|
|
92
|
+
def retry_failed(max_retries: nil) # -> int
|
|
76
93
|
return 0 unless @backend.respond_to?(:retry_failed)
|
|
77
|
-
@backend.retry_failed(@topic, max_retries: @max_retries)
|
|
94
|
+
@backend.retry_failed(@topic, max_retries: max_retries || @max_retries)
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
# Produce a message onto a topic. Convenience wrapper around push().
|
|
81
|
-
def produce(topic, payload, priority
|
|
82
|
-
|
|
98
|
+
def produce(topic, payload, priority: 0, delay_seconds: 0)
|
|
99
|
+
available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
|
|
100
|
+
message = Job.new(topic: topic, payload: payload, priority: priority, available_at: available_at, queue: self)
|
|
83
101
|
@backend.enqueue(message)
|
|
84
102
|
message
|
|
85
103
|
end
|
|
@@ -108,7 +126,7 @@ module Tina4
|
|
|
108
126
|
# queue.consume("emails", poll_interval: 5) { |job| process(job) }
|
|
109
127
|
# queue.consume("emails", id: "abc-123") { |job| process(job) }
|
|
110
128
|
#
|
|
111
|
-
def consume(topic = nil, id: nil, poll_interval: 1.0, iterations: 0, &block)
|
|
129
|
+
def consume(topic = nil, id: nil, poll_interval: 1.0, iterations: 0, batch_size: 1, &block)
|
|
112
130
|
topic ||= @topic
|
|
113
131
|
|
|
114
132
|
if id
|
|
@@ -125,16 +143,30 @@ module Tina4
|
|
|
125
143
|
# iterations>0 → stop after consuming N jobs
|
|
126
144
|
if block_given?
|
|
127
145
|
consumed = 0
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
if batch_size > 1
|
|
147
|
+
loop do
|
|
148
|
+
jobs = pop_batch(batch_size)
|
|
149
|
+
if jobs.empty?
|
|
150
|
+
break if poll_interval <= 0
|
|
151
|
+
sleep(poll_interval)
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
yield jobs
|
|
155
|
+
consumed += jobs.length
|
|
156
|
+
break if iterations > 0 && consumed >= iterations
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
loop do
|
|
160
|
+
job = @backend.dequeue(topic)
|
|
161
|
+
if job.nil?
|
|
162
|
+
break if poll_interval <= 0
|
|
163
|
+
sleep(poll_interval)
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
yield job
|
|
167
|
+
consumed += 1
|
|
168
|
+
break if iterations > 0 && consumed >= iterations
|
|
134
169
|
end
|
|
135
|
-
yield job
|
|
136
|
-
consumed += 1
|
|
137
|
-
break if iterations > 0 && consumed >= iterations
|
|
138
170
|
end
|
|
139
171
|
else
|
|
140
172
|
Enumerator.new do |yielder|
|
|
@@ -178,6 +210,49 @@ module Tina4
|
|
|
178
210
|
end
|
|
179
211
|
end
|
|
180
212
|
|
|
213
|
+
# Get the topic name this queue was constructed with.
|
|
214
|
+
def get_topic
|
|
215
|
+
@topic
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Consume all available jobs and pass each to handler, then stop.
|
|
219
|
+
#
|
|
220
|
+
# Simpler alternative to consume() for drain-and-exit use cases.
|
|
221
|
+
#
|
|
222
|
+
# queue.process { |job| handle(job); job.complete }
|
|
223
|
+
# queue.process(topic: "emails", max_jobs: 10) { |job| ... }
|
|
224
|
+
#
|
|
225
|
+
def process(topic: nil, max_jobs: nil, batch_size: 1, &handler)
|
|
226
|
+
raise ArgumentError, "block required" unless block_given?
|
|
227
|
+
drain_topic = topic || @topic
|
|
228
|
+
processed = 0
|
|
229
|
+
loop do
|
|
230
|
+
break if max_jobs && processed >= max_jobs
|
|
231
|
+
if batch_size > 1
|
|
232
|
+
remaining = max_jobs ? [batch_size, max_jobs - processed].min : batch_size
|
|
233
|
+
jobs = @backend.respond_to?(:dequeue_batch) ?
|
|
234
|
+
@backend.dequeue_batch(drain_topic, remaining) :
|
|
235
|
+
(1..remaining).map { @backend.dequeue(drain_topic) }.compact
|
|
236
|
+
break if jobs.empty?
|
|
237
|
+
begin
|
|
238
|
+
handler.call(jobs)
|
|
239
|
+
rescue => e
|
|
240
|
+
jobs.each { |job| job.fail(e.message) }
|
|
241
|
+
end
|
|
242
|
+
processed += jobs.length
|
|
243
|
+
else
|
|
244
|
+
job = @backend.dequeue(drain_topic)
|
|
245
|
+
break if job.nil?
|
|
246
|
+
begin
|
|
247
|
+
handler.call(job)
|
|
248
|
+
rescue => e
|
|
249
|
+
job.fail(e.message)
|
|
250
|
+
end
|
|
251
|
+
processed += 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
181
256
|
# Get the underlying backend instance.
|
|
182
257
|
def backend
|
|
183
258
|
@backend
|