gemstack 0.2.5 → 0.3.0

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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # English inflections used by conventions (routes, generators, constants).
5
+ #
6
+ # Inflector.pluralize("category") # => "categories"
7
+ # Inflector.camelize("inventory_item") # => "InventoryItem"
8
+ # Inflector.underscore("InventoryItem")# => "inventory_item"
9
+ #
10
+ # Extend with Inflector.irregular("person", "people") or
11
+ # Inflector.uncountable("equipment") — typically in config/app.rb.
12
+ module Inflector
13
+ PLURALS = [
14
+ [/(quiz)\z/i, '\1zes'],
15
+ [/\A(ox)\z/i, '\1en'],
16
+ [/(matr|vert|ind)(?:ix|ex)\z/i, '\1ices'],
17
+ [/(x|ch|ss|sh|zz)\z/i, '\1es'],
18
+ [/([^aeiouy]|qu)y\z/i, '\1ies'],
19
+ [/(?:([^f])fe|([lr])f)\z/i, '\1\2ves'],
20
+ [/sis\z/i, "ses"],
21
+ [/([ti])um\z/i, '\1a'],
22
+ [/(buffal|tomat|potat|her|ech)o\z/i, '\1oes'],
23
+ [/(bu|mis|ga|alia|statu|stat|vir|octop|cact)us\z/i, '\1uses'],
24
+ [/s\z/i, "s"],
25
+ [/\z/, "s"]
26
+ ].freeze
27
+
28
+ SINGULARS = [
29
+ [/(quiz)zes\z/i, '\1'],
30
+ [/(matr)ices\z/i, '\1ix'],
31
+ [/(vert|ind)ices\z/i, '\1ex'],
32
+ [/\A(ox)en/i, '\1'],
33
+ [/(alias|status|bus|campus|virus|octopus|cactus)(es)?\z/i, '\1'],
34
+ [/(x|ch|ss|sh|zz)es\z/i, '\1'],
35
+ [/(m)ovies\z/i, '\1ovie'],
36
+ [/([^aeiouy]|qu)ies\z/i, '\1y'],
37
+ [/([lr])ves\z/i, '\1f'],
38
+ [/([^f])ves\z/i, '\1fe'],
39
+ [/(analy|ba|diagno|parenthe|progno|synop|the)ses\z/i, '\1sis'],
40
+ [/(buffal|tomat|potat|her|ech)oes\z/i, '\1o'],
41
+ [/([ti])a\z/i, '\1um'],
42
+ [/ss\z/i, "ss"],
43
+ [/s\z/i, ""]
44
+ ].freeze
45
+
46
+ DEFAULT_IRREGULARS = {
47
+ "person" => "people", "man" => "men", "woman" => "women", "child" => "children",
48
+ "mouse" => "mice", "goose" => "geese", "tooth" => "teeth", "foot" => "feet"
49
+ }.freeze
50
+
51
+ DEFAULT_UNCOUNTABLES = %w[equipment information rice money species series fish sheep deer news data].freeze
52
+
53
+ @irregulars = DEFAULT_IRREGULARS.dup
54
+ @uncountables = DEFAULT_UNCOUNTABLES.dup
55
+
56
+ class << self
57
+ def irregular(singular, plural)
58
+ @irregulars[singular.downcase] = plural.downcase
59
+ end
60
+
61
+ def uncountable(*words)
62
+ @uncountables.concat(words.flatten.map(&:downcase))
63
+ end
64
+
65
+ def pluralize(word)
66
+ inflect(word.to_s, @irregulars, PLURALS)
67
+ end
68
+
69
+ def singularize(word)
70
+ inflect(word.to_s, @irregulars.invert, SINGULARS)
71
+ end
72
+
73
+ # "inventory_item" / "inventory-item" => "InventoryItem";
74
+ # "admin/products" => "Admin::Products"
75
+ def camelize(term)
76
+ term.to_s.split("/").map do |part|
77
+ part.split(/[_-]/).map { |w| w[0] ? w[0].upcase + w[1..] : w }.join
78
+ end.join("::")
79
+ end
80
+
81
+ # "InventoryItem" => "inventory_item"; "Admin::Products" => "admin/products"
82
+ def underscore(term)
83
+ term.to_s.gsub("::", "/")
84
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
85
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
86
+ .tr("-", "_")
87
+ .downcase
88
+ end
89
+
90
+ def dasherize(term) = underscore(term).tr("_", "-")
91
+
92
+ # "inventory_item" => "Inventory item"
93
+ def humanize(term)
94
+ words = underscore(term).delete_suffix("_id").tr("_", " ")
95
+ words[0] ? words[0].upcase + words[1..] : words
96
+ end
97
+
98
+ # "inventory_items" => "InventoryItem"
99
+ def classify(term) = camelize(singularize(term.to_s))
100
+
101
+ # "InventoryItem" => "inventory_items"
102
+ def tableize(term) = pluralize(underscore(term))
103
+
104
+ private
105
+
106
+ # irregulars maps source form => target form (e.g. "person" => "people").
107
+ def inflect(word, irregulars, rules)
108
+ return word if word.empty?
109
+
110
+ prefix, last = split_last_word(word)
111
+ lower = last.downcase
112
+ return word if @uncountables.include?(lower) || irregulars.value?(lower)
113
+ return prefix + match_case(last, irregulars[lower]) if irregulars.key?(lower)
114
+
115
+ rules.each do |pattern, replacement|
116
+ return prefix + last.sub(pattern, replacement) if last.match?(pattern)
117
+ end
118
+ word
119
+ end
120
+
121
+ # Only the final word is inflected: "line_item" => ["line_", "item"],
122
+ # "LineItem" => ["Line", "Item"].
123
+ def split_last_word(word)
124
+ match = word.match(/\A(.*[_\-\s])([^_\-\s]+)\z/m) || word.match(/\A(.*[a-z\d])([A-Z][^A-Z]*)\z/)
125
+ match ? [match[1], match[2]] : ["", word]
126
+ end
127
+
128
+ def match_case(original, replacement)
129
+ original[0] == original[0].upcase ? replacement[0].upcase + replacement[1..] : replacement
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # Base class for background jobs. See GemStack::Jobs.
5
+ #
6
+ # class ImportProducts < GemStack::Job
7
+ # queue :imports
8
+ # priority 10 # lower runs first (default 100)
9
+ # retry_on Faraday::TimeoutError, attempts: 5, wait: :exponential
10
+ # discard_on GemStack::NotFound # the record is gone; nothing to do
11
+ #
12
+ # def perform(import_id, options = {})
13
+ # ...
14
+ # end
15
+ # end
16
+ #
17
+ # ImportProducts.perform_later(import.id) # => job id
18
+ # ImportProducts.set(wait: 300, queue: "slow").perform_later(1) # in 5 minutes
19
+ # ImportProducts.set(at: Time.now + 3600).perform_later(1)
20
+ # ImportProducts.perform_now(1) # synchronously
21
+ #
22
+ # Arguments must be JSON values (strings, numbers, booleans, nil, arrays,
23
+ # hashes with string or symbol keys). Pass ids, not records. Hash keys come
24
+ # back as strings.
25
+ class Job
26
+ RetryRule = Struct.new(:classes, :attempts, :wait)
27
+
28
+ class << self
29
+ def queue(name = nil)
30
+ @queue = name.to_s if name
31
+ @queue || (superclass.respond_to?(:queue) ? superclass.queue : Jobs.config.default_queue)
32
+ end
33
+
34
+ def priority(value = nil)
35
+ @priority = Integer(value) if value
36
+ @priority || (superclass.respond_to?(:priority) ? superclass.priority : Jobs.config.default_priority)
37
+ end
38
+
39
+ # retry_on Net::ReadTimeout, attempts: 5, wait: 30 # fixed seconds
40
+ # retry_on StandardError, wait: :exponential # default
41
+ # retry_on Api::RateLimited, wait: ->(attempt) { attempt * 60 }
42
+ # The first matching rule (most recently declared first) decides.
43
+ def retry_on(*classes, attempts: nil, wait: :exponential)
44
+ retry_rules.unshift(RetryRule.new(classes, attempts, wait))
45
+ end
46
+
47
+ # Errors that mean the job should simply be dropped (logged, not retried).
48
+ def discard_on(*classes)
49
+ discard_classes.concat(classes)
50
+ end
51
+
52
+ def retry_rules = @retry_rules ||= superclass.respond_to?(:retry_rules) ? superclass.retry_rules.dup : []
53
+
54
+ def discard_classes
55
+ @discard_classes ||= superclass.respond_to?(:discard_classes) ? superclass.discard_classes.dup : []
56
+ end
57
+
58
+ def perform_later(*) = Enqueuer.new(self).perform_later(*)
59
+ def set(**) = Enqueuer.new(self, **)
60
+
61
+ def perform_now(*args)
62
+ new.perform(*Jobs::Arguments.load(Jobs::Arguments.dump(args)))
63
+ end
64
+
65
+ # [max attempts, seconds to wait before the next attempt] for an error
66
+ # raised on attempt number `attempt` (1-based); nil wait = give up.
67
+ def retry_decision(error, attempt)
68
+ rule = retry_rules.find { |r| r.classes.any? { |klass| error.is_a?(klass) } }
69
+ max = rule&.attempts || Jobs.config.default_max_attempts
70
+ return [max, nil] if attempt >= max
71
+
72
+ [max, backoff(rule&.wait || :exponential, attempt)]
73
+ end
74
+
75
+ def discard?(error) = discard_classes.any? { |klass| error.is_a?(klass) }
76
+
77
+ private
78
+
79
+ # Sidekiq's curve: 16s, 31s, 96s, 271s, … ≈ 4 hours over 10 attempts.
80
+ def backoff(wait, attempt)
81
+ case wait
82
+ when :exponential then (attempt**4) + 15 + (rand(10) * attempt)
83
+ when Proc then Float(wait.call(attempt))
84
+ else Float(wait)
85
+ end
86
+ end
87
+ end
88
+
89
+ # Builds the payload for a (possibly customised) enqueue.
90
+ class Enqueuer
91
+ def initialize(job_class, wait: nil, at: nil, queue: nil, priority: nil)
92
+ @job_class = job_class
93
+ @run_at = at || (wait && (Time.now + Float(wait)))
94
+ @queue = queue&.to_s
95
+ @priority = priority
96
+ end
97
+
98
+ def perform_later(*args)
99
+ payload = {
100
+ "job_class" => @job_class.name,
101
+ "queue" => @queue || @job_class.queue,
102
+ "priority" => @priority || @job_class.priority,
103
+ "args" => Jobs::Arguments.dump(args),
104
+ "run_at" => @run_at
105
+ }
106
+ raise ArgumentError, "anonymous job classes can't be enqueued" unless payload["job_class"]
107
+
108
+ id = Jobs.adapter.enqueue(payload)
109
+ Jobs.instrument(:enqueued, job_class: payload["job_class"], job_id: id, queue: payload["queue"],
110
+ run_at: payload["run_at"])
111
+ id
112
+ end
113
+ end
114
+
115
+ # Subclasses implement this.
116
+ def perform(*)
117
+ raise NotImplementedError, "#{self.class.name}#perform is not implemented"
118
+ end
119
+
120
+ # Set by the executor for the running job.
121
+ attr_accessor :job_id, :attempt
122
+ end
123
+
124
+ module Jobs
125
+ # Validates and converts arguments to/from their JSON form.
126
+ module Arguments
127
+ module_function
128
+
129
+ def dump(args) = args.map { |arg| dump_value(arg, "argument") }
130
+
131
+ def load(json) = json
132
+
133
+ def dump_value(value, path)
134
+ case value
135
+ when String, Integer, true, false, nil then value
136
+ when Float then value.finite? ? value : invalid(value, path)
137
+ when Symbol then value.name
138
+ when Array then value.each_with_index.map { |v, i| dump_value(v, "#{path}[#{i}]") }
139
+ when Hash
140
+ value.to_h do |key, v|
141
+ invalid(key, "#{path} key") unless key.is_a?(String) || key.is_a?(Symbol)
142
+ [key.to_s, dump_value(v, "#{path}[#{key.inspect}]")]
143
+ end
144
+ else invalid(value, path)
145
+ end
146
+ end
147
+
148
+ def invalid(value, path)
149
+ hint = value.respond_to?(:pk) || value.respond_to?(:id) ? " — pass its id instead" : ""
150
+ raise SerializationError, "#{path} #{value.class} can't be serialized to JSON#{hint}"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Jobs
5
+ module Adapters
6
+ # An in-process thread pool with an in-memory schedule (retries and
7
+ # `set(wait:)` work). Jobs are lost when the process exits, so it is
8
+ # meant for development and apps without a database — use :postgres
9
+ # or :sidekiq in production.
10
+ class Async
11
+ include AfterCommit
12
+
13
+ Entry = Struct.new(:run_at, :sequence, :payload)
14
+
15
+ def initialize(concurrency: Jobs.config.concurrency)
16
+ @concurrency = concurrency
17
+ @entries = []
18
+ @mutex = Mutex.new
19
+ @available = ConditionVariable.new
20
+ @sequence = 0
21
+ @running = 0
22
+ @threads = nil
23
+ end
24
+
25
+ def enqueue(payload)
26
+ id = SecureRandom.uuid
27
+ after_commit { schedule(payload.merge("id" => id, "attempts" => 0), payload["run_at"] || Time.now) }
28
+ id
29
+ end
30
+
31
+ # Blocks until no jobs are queued or running (mainly for tests).
32
+ def drain(timeout: 10)
33
+ deadline = monotonic + timeout
34
+ sleep 0.01 until @mutex.synchronize { @entries.empty? && @running.zero? } || monotonic > deadline
35
+ end
36
+
37
+ def shutdown
38
+ @mutex.synchronize do
39
+ @stopping = true
40
+ @available.broadcast
41
+ end
42
+ @threads&.each { |thread| thread.join(Jobs.config.shutdown_timeout) }
43
+ @threads = nil
44
+ end
45
+
46
+ private
47
+
48
+ def schedule(payload, run_at)
49
+ worker_threads
50
+ @mutex.synchronize do
51
+ @sequence += 1
52
+ @entries << Entry.new(run_at, @sequence, payload)
53
+ @entries.sort_by! { |e| [e.run_at, e.payload["priority"] || 100, e.sequence] }
54
+ @available.broadcast
55
+ end
56
+ end
57
+
58
+ # Started lazily on the first job; shutdown joins them.
59
+ def worker_threads
60
+ @mutex.synchronize do
61
+ @threads ||= Array.new(@concurrency) { Thread.new { work } } # rubocop:disable Naming/MemoizedInstanceVariableName
62
+ end
63
+ end
64
+
65
+ def work
66
+ loop do
67
+ entry = next_entry or break
68
+ outcome = Executor.execute(entry.payload)
69
+ schedule(entry.payload.merge("attempts" => outcome.attempt), outcome.run_at) if outcome.status == :retry
70
+ ensure
71
+ @mutex.synchronize { @running -= 1 } if entry
72
+ end
73
+ end
74
+
75
+ def next_entry
76
+ @mutex.synchronize do
77
+ loop do
78
+ return nil if @stopping
79
+
80
+ first = @entries.first
81
+ if first && first.run_at <= Time.now
82
+ @running += 1
83
+ return @entries.shift
84
+ end
85
+ @available.wait(@mutex, first ? [first.run_at - Time.now, 0.01].max : nil)
86
+ end
87
+ end
88
+ end
89
+
90
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Jobs
5
+ # The migration new apps get (and `gemstack jobs:install` writes). Kept
6
+ # as source text so the app owns a plain, readable migration while tests
7
+ # and the generator share one definition.
8
+ module Migration
9
+ SOURCE = <<~RUBY
10
+ # frozen_string_literal: true
11
+
12
+ # The GemStack job queue (docs/background-jobs.md), for PostgreSQL, MySQL and
13
+ # SQLite. Workers claim rows one at a time (FOR UPDATE SKIP LOCKED where the
14
+ # database has it); finished jobs are deleted, exhausted ones keep failed_at.
15
+ Sequel.migration do
16
+ up do
17
+ partial = database_type != :mysql # MySQL has no partial indexes
18
+ ready = partial ? { where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL") } : {}
19
+ create_table(:gemstack_jobs) do
20
+ primary_key :id, type: :Bignum
21
+ String :queue, null: false, default: "default"
22
+ Integer :priority, null: false, default: 100
23
+ String :job_class, null: false
24
+ column :args, :jsonb, null: false # json on MySQL and SQLite
25
+ column :run_at, :timestamptz, null: false
26
+ Integer :attempts, null: false, default: 0
27
+ String :last_error, text: true
28
+ column :locked_at, :timestamptz
29
+ String :locked_by
30
+ column :failed_at, :timestamptz
31
+ column :created_at, :timestamptz, null: false
32
+
33
+ # The fetch query: ready jobs by queue, in priority/run_at order.
34
+ index %i[queue priority run_at id], name: :gemstack_jobs_ready, **ready
35
+ index :locked_at, name: :gemstack_jobs_locked
36
+ index :failed_at, name: :gemstack_jobs_failed
37
+ end
38
+ end
39
+
40
+ down do
41
+ drop_table(:gemstack_jobs)
42
+ end
43
+ end
44
+ RUBY
45
+
46
+ def self.apply(db, direction = :up)
47
+ Sequel.extension :migration
48
+ eval(SOURCE, TOPLEVEL_BINDING, "gemstack_jobs_migration.rb").apply(db, direction) # rubocop:disable Security/Eval
49
+ end
50
+ end
51
+
52
+ module Adapters
53
+ # The default adapter: jobs are rows in the application's database
54
+ # — PostgreSQL, MySQL or SQLite.
55
+ #
56
+ # - Enqueueing is an INSERT on the current connection, so inside
57
+ # GemStack.transaction a job exists only if the transaction commits.
58
+ # - Workers claim one job at a time in a short transaction: with
59
+ # FOR UPDATE SKIP LOCKED on PostgreSQL and MySQL 8, so workers never
60
+ # block each other; on SQLite by taking the write lock up front.
61
+ # - PostgreSQL also NOTIFYs idle workers at once; elsewhere (and as a
62
+ # safety net) workers poll every config.jobs.poll_interval.
63
+ # - Times are set from Ruby, in UTC, so the database clock and time zone
64
+ # never matter.
65
+ class Database
66
+ CHANNEL = "gemstack_jobs"
67
+
68
+ def initialize(db: nil, table: Jobs.config.table)
69
+ @db = db
70
+ @table = table
71
+ end
72
+
73
+ def db
74
+ @db || begin
75
+ require "gemstack/db"
76
+ GemStack::DB.connection
77
+ end
78
+ end
79
+
80
+ def dataset = db[@table]
81
+ def postgres? = db.database_type == :postgres
82
+
83
+ def enqueue(payload)
84
+ now = Time.now
85
+ args = postgres? ? Sequel.pg_jsonb_wrap(payload["args"]) : JSON.generate(payload["args"])
86
+ id = dataset.insert(job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
87
+ args: args, run_at: payload["run_at"] || now, created_at: now)
88
+ db.notify(CHANNEL, payload: payload["queue"]) if postgres?
89
+ id
90
+ end
91
+
92
+ # Claims the next ready job for these queues ("*" = all). Returns a
93
+ # payload Hash or nil.
94
+ def claim(queues, worker)
95
+ now = Time.now
96
+ db.transaction(**claim_options) do
97
+ ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= now }
98
+ ready = ready.where(queue: queues) unless queues.include?("*")
99
+ ready = ready.order(:priority, :run_at, :id).limit(1)
100
+ ready = ready.for_update.skip_locked if ready.supports_skip_locked?
101
+ row = ready.select(*returned_columns).first
102
+ dataset.where(id: row[:id]).update(locked_at: now, locked_by: worker) if row
103
+ row && payload_for(row)
104
+ end
105
+ end
106
+
107
+ def complete(id) = dataset.where(id: id).delete
108
+
109
+ def reschedule(id, run_at:, attempts:, error:)
110
+ dataset.where(id: id).update(locked_at: nil, locked_by: nil, run_at: run_at, attempts: attempts,
111
+ last_error: describe(error))
112
+ end
113
+
114
+ def fail(id, attempts:, error:)
115
+ return complete(id) unless Jobs.config.keep_failed
116
+
117
+ dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Time.now,
118
+ attempts: attempts, last_error: describe(error))
119
+ end
120
+
121
+ # Releases this worker's claimed jobs (graceful shutdown).
122
+ def release(worker) = dataset.where(locked_by: worker).update(locked_at: nil, locked_by: nil)
123
+
124
+ # Releases jobs locked longer than `timeout` seconds (their worker died).
125
+ def release_stale(timeout)
126
+ cutoff = Time.now - Float(timeout)
127
+ dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
128
+ end
129
+
130
+ # Failed jobs back to the queue: all, or the given ids.
131
+ def retry_failed(ids = nil)
132
+ failed = dataset.exclude(failed_at: nil)
133
+ failed = failed.where(id: ids) if ids
134
+ failed.update(failed_at: nil, attempts: 0, run_at: Time.now, last_error: nil)
135
+ end
136
+
137
+ def discard_failed(ids = nil)
138
+ failed = dataset.exclude(failed_at: nil)
139
+ failed = failed.where(id: ids) if ids
140
+ failed.delete
141
+ end
142
+
143
+ def failed(limit: 20)
144
+ dataset.exclude(failed_at: nil).order(Sequel.desc(:failed_at)).limit(limit)
145
+ .select(:id, :queue, :job_class, :attempts, :failed_at, :last_error).all
146
+ end
147
+
148
+ # { "default" => { ready:, scheduled:, running:, failed: }, ... }
149
+ def stats
150
+ now = Time.now
151
+ states = Sequel.case(
152
+ [[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
153
+ [Sequel[:run_at] > now, "scheduled"]], "ready"
154
+ )
155
+ dataset.group_and_count(:queue, states.as(:state)).all.each_with_object({}) do |row, result|
156
+ (result[row[:queue]] ||= { ready: 0, scheduled: 0, running: 0, failed: 0 })[row[:state].to_sym] =
157
+ row[:count]
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ # SQLite: take the write lock when the transaction starts, so two
164
+ # workers can't both read the same ready row.
165
+ def claim_options = db.database_type == :sqlite ? { mode: :immediate } : {}
166
+
167
+ # args as text, parsed with the json gem: jobs receive plain Hash/Array
168
+ # values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
169
+ def returned_columns
170
+ @returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
171
+ postgres? ? Sequel.cast(:args, :text).as(:args_json) : Sequel.as(:args, :args_json)]
172
+ end
173
+
174
+ def payload_for(row)
175
+ { "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
176
+ "priority" => row[:priority], "args" => JSON.parse(row[:args_json].to_s), "attempts" => row[:attempts] }
177
+ end
178
+
179
+ def describe(error)
180
+ "#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
181
+ end
182
+ end
183
+
184
+ # The adapter's name before it supported MySQL and SQLite; `adapter: :postgres` still works.
185
+ Postgres = Database
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Jobs
5
+ module Adapters
6
+ # Runs jobs immediately, in the caller's thread, ignoring schedules and
7
+ # retries. Errors propagate, so nothing is silently swallowed. Handy for
8
+ # scripts and debugging.
9
+ class Inline
10
+ def enqueue(payload)
11
+ id = SecureRandom.uuid
12
+ job = Executor.resolve(payload["job_class"]).new
13
+ job.job_id = id
14
+ job.attempt = 1
15
+ job.perform(*Arguments.load(payload["args"]))
16
+ id
17
+ end
18
+ end
19
+
20
+ # Defers a block until the surrounding database transaction commits
21
+ # (when gemstack/db is loaded), so a job never runs before — or without —
22
+ # the data it depends on. The :postgres adapter gets this for free by
23
+ # inserting into the same transaction.
24
+ module AfterCommit
25
+ def after_commit(&)
26
+ db = defined?(GemStack::DB) && GemStack::DB.connected? ? GemStack::DB.connection : nil
27
+ return yield unless db&.in_transaction?
28
+
29
+ db.after_commit(&)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Jobs
5
+ module Adapters
6
+ # Runs GemStack jobs on Sidekiq (for teams already running Redis +
7
+ # Sidekiq). Add `gem "sidekiq"`, set `config.jobs.adapter = :sidekiq`,
8
+ # and run `bundle exec sidekiq -r ./config/sidekiq.rb` (see docs/background-jobs.md).
9
+ #
10
+ # GemStack's executor still decides retries and discards, so job
11
+ # classes behave identically on every adapter; Sidekiq's own retries
12
+ # are off and exhausted jobs go to Sidekiq's Dead set.
13
+ class Sidekiq
14
+ include AfterCommit
15
+
16
+ def initialize
17
+ require "sidekiq"
18
+ Runner.define!
19
+ rescue LoadError
20
+ raise ConfigurationError, 'the :sidekiq job adapter needs `gem "sidekiq"` in the Gemfile'
21
+ end
22
+
23
+ def enqueue(payload)
24
+ jid = SecureRandom.hex(12)
25
+ after_commit { Runner.push(payload.merge("attempts" => 0), jid: jid) }
26
+ jid
27
+ end
28
+
29
+ # The Sidekiq job class that executes GemStack payloads.
30
+ module Runner
31
+ module_function
32
+
33
+ def define!
34
+ return if defined?(GemStack::Jobs::SidekiqRunner)
35
+
36
+ klass = Class.new do
37
+ include ::Sidekiq::Job
38
+
39
+ sidekiq_options retry: 0 # exhausted jobs → Dead set; GemStack schedules retries itself
40
+
41
+ def perform(payload)
42
+ payload = payload.merge("id" => jid)
43
+ outcome = Executor.execute(payload)
44
+ case outcome.status
45
+ when :retry then Runner.push(payload.merge("attempts" => outcome.attempt), at: outcome.run_at)
46
+ when :failed then raise outcome.error
47
+ end
48
+ end
49
+ end
50
+ GemStack::Jobs.const_set(:SidekiqRunner, klass)
51
+ end
52
+
53
+ def push(payload, at: nil, jid: nil)
54
+ item = { "class" => GemStack::Jobs::SidekiqRunner, "queue" => payload["queue"],
55
+ "args" => [payload.except("run_at", "id")] }
56
+ item["jid"] = jid if jid
57
+ run_at = at || payload["run_at"]
58
+ item["at"] = run_at.to_f if run_at
59
+ ::Sidekiq::Client.push(item)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end