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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47837a1dc3df55eebfe8a54a2fdec217c6fa7821ed78fcad80f786bf0e2e2070
4
- data.tar.gz: f3e13a9280d9aec92a8aa9739cdd55f360a2c46caacc4b5c3c188c1fb77bc17a
3
+ metadata.gz: 72fb1f0292d7127940eaf3eab4c5e701a4150c43278fb2579065685700b60ff1
4
+ data.tar.gz: be15ee4fb994153c86320b4218adcfa31bd5e94db1d4a14f9e9e38ecd528338a
5
5
  SHA512:
6
- metadata.gz: 7efc4225b421fda7a3e9c1bc133a80a131d2d7cfcc34b1549bd4d9778b16ba247dc7689bca4315fb3c27ecf5ce86ebbe23cb09db88fbb361b21589c12296aad9
7
- data.tar.gz: dbc2d8f2f0a1ced22395d1c095802846f61ef04c85657c7a8ea12875d166907fb3320cfd0fe4f9ebfd814e93991538670895a53997a965168fbb5f40a3ed81c1
6
+ metadata.gz: ed1794c6bc7e19ef464e152b166d901a06f9ee05f0c3e5fa3b2f56e2531a92208250b321704f357b2d04e53f482acba05208edd0b4ff9cd4a3d85a1427b912bc
7
+ data.tar.gz: 5b25b7a0c6e7758ee015f541907df8556fe8160c15558db7dbaa0c1fa1aad451cfcf324d5f3e8e80960193c8166eb39296005cd073af240e3812eaaad5725ea3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ The framework gem now contains core, cache, schema, http, db, jobs, mail, storage, contract, dev and the CLI code. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
3
7
  ## 0.2.5
4
8
 
5
9
  See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  GemStack: a fast, modular Ruby API framework for Next.js applications.
4
4
 
5
5
  Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
7
- version.
6
+ applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed
7
+ together in that repository and released with the same version.
8
8
 
9
9
  ## Installation
10
10
 
@@ -13,7 +13,11 @@ gem install gemstack
13
13
  gemstack new myapp
14
14
  ```
15
15
 
16
- This gem is the framework: it depends on every module an app needs (core, cache, schema, http, contract, dev, cli). Optional modules are added per app.
16
+ This gem is the framework — routing, controllers, models (SQLite, PostgreSQL, MySQL), background jobs,
17
+ mail, file storage, the TypeScript contract, generators and the development server. Apps switch modules
18
+ on in `config/app.rb` (`require "gemstack/db"`, …). Authentication and realtime are the
19
+ [gemstack-auth](https://rubygems.org/gems/gemstack-auth) and
20
+ [gemstack-realtime](https://rubygems.org/gems/gemstack-realtime) gems.
17
21
 
18
22
  ## Documentation
19
23
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Cache
5
+ # A thread-safe, per-process LRU cache with expiry. Values are stored
6
+ # marshalled, so callers never share (and can't mutate) cached objects —
7
+ # the same semantics as the Redis store. With several Puma workers each
8
+ # has its own copy; use Redis to share a cache across processes.
9
+ class MemoryStore < Store
10
+ Entry = Struct.new(:data, :expires_at)
11
+
12
+ def initialize(max_entries: 10_000, **)
13
+ super(**)
14
+ @max_entries = max_entries
15
+ @entries = {}
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def size = @entries.size
20
+ def clear = @mutex.synchronize { @entries.clear }
21
+
22
+ def increment(key, by = 1, expires_in: default_expires_in)
23
+ normalized = normalize(key)
24
+ @mutex.synchronize do
25
+ entry = live_entry(normalized)
26
+ value = (entry ? Integer(load(entry.data)) : 0) + by
27
+ store(normalized, value, entry ? entry.expires_at : expires_at(expires_in))
28
+ value
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def read_entry(key)
35
+ @mutex.synchronize do
36
+ entry = live_entry(key) or return MISSING
37
+ @entries[key] = @entries.delete(key) # mark as most recently used
38
+ load(entry.data)
39
+ end
40
+ end
41
+
42
+ def write_entry(key, value, expires_in)
43
+ data = dump(value)
44
+ @mutex.synchronize { store_data(key, data, expires_at(expires_in)) }
45
+ end
46
+
47
+ def delete_entry(key) = @mutex.synchronize { !@entries.delete(key).nil? }
48
+
49
+ # Callers hold the mutex.
50
+ def live_entry(key)
51
+ entry = @entries[key]
52
+ return nil unless entry
53
+ return entry unless entry.expires_at && entry.expires_at <= monotonic
54
+
55
+ @entries.delete(key)
56
+ nil
57
+ end
58
+
59
+ def store(key, value, expires_at) = store_data(key, dump(value), expires_at)
60
+
61
+ def store_data(key, data, expires_at)
62
+ @entries.delete(key)
63
+ @entries[key] = Entry.new(data, expires_at)
64
+ @entries.shift while @entries.size > @max_entries # Hash order = LRU order
65
+ true
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Cache
5
+ # Caches nothing: fetch always runs its block. The default in tests, so
6
+ # tests never depend on state cached by earlier tests.
7
+ class NullStore < Store
8
+ def clear = true
9
+ def increment(_key, by = 1, **) = by
10
+
11
+ private
12
+
13
+ def read_entry(_key) = MISSING
14
+ def write_entry(_key, _value, _expires_in) = true
15
+ def delete_entry(_key) = false
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Cache
5
+ # A cache shared by every process, through Redis. Uses the `redis-client`
6
+ # gem (add `gem "redis-client"` to the Gemfile) with a connection pool
7
+ # sized to the server's threads, or any client object you pass that
8
+ # responds to #call(*command) (a redis-client pool or connection).
9
+ #
10
+ # config.cache.store = :redis # REDIS_URL
11
+ # config.cache.store = GemStack::Cache::RedisStore.new(client: my_pool, namespace: "shop")
12
+ class RedisStore < Store
13
+ attr_reader :client
14
+
15
+ def initialize(url: nil, client: nil, pool_size: 5, **)
16
+ super(**)
17
+ @client = client || build_client(url, pool_size)
18
+ end
19
+
20
+ def increment(key, by = 1, expires_in: default_expires_in)
21
+ normalized = normalize(key)
22
+ value = client.call("INCRBY", normalized, by)
23
+ client.call("PEXPIRE", normalized, (Float(expires_in) * 1000).round, "NX") if expires_in
24
+ value
25
+ end
26
+
27
+ # Deletes only keys in this store's namespace (never FLUSHDB).
28
+ def clear
29
+ raise Error, "RedisStore#clear needs a namespace (config.cache.namespace)" unless namespace
30
+
31
+ cursor = "0"
32
+ loop do
33
+ cursor, keys = client.call("SCAN", cursor, "MATCH", "#{namespace}:*", "COUNT", 500)
34
+ client.call("UNLINK", *keys) unless keys.empty?
35
+ break if cursor == "0"
36
+ end
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def read_entry(key)
43
+ data = client.call("GET", key)
44
+ return MISSING if data.nil?
45
+
46
+ # Counters are stored as plain integers so INCRBY works on them.
47
+ data.match?(/\A-?\d+\z/) ? Integer(data) : load(data.b)
48
+ end
49
+
50
+ def write_entry(key, value, expires_in)
51
+ data = value.is_a?(Integer) ? value.to_s : dump(value)
52
+ if expires_in
53
+ client.call("SET", key, data, "PX", (Float(expires_in) * 1000).round)
54
+ else
55
+ client.call("SET", key, data)
56
+ end
57
+ true
58
+ end
59
+
60
+ def delete_entry(key) = client.call("DEL", key).positive?
61
+
62
+ def build_client(url, pool_size)
63
+ require "redis-client"
64
+ RedisClient.config(url: url).new_pool(size: pool_size, timeout: 1.0)
65
+ rescue LoadError
66
+ raise ConfigurationError, 'the Redis cache store needs `gem "redis-client"` in the Gemfile'
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GemStack
6
+ module Cache
7
+ # The store interface. Subclasses implement read_entry / write_entry /
8
+ # delete_entry / clear (and may override increment for atomicity).
9
+ #
10
+ # Keys may be strings, symbols, arrays of parts, or objects with
11
+ # #cache_key (GemStack models have one); they are namespaced and hashed
12
+ # when longer than MAX_KEY bytes. Values must be Marshal-able.
13
+ class Store
14
+ MAX_KEY = 200
15
+ MISSING = Object.new.freeze
16
+
17
+ attr_reader :namespace, :default_expires_in
18
+
19
+ def initialize(namespace: nil, default_expires_in: nil)
20
+ @namespace = namespace&.to_s
21
+ @default_expires_in = default_expires_in
22
+ end
23
+
24
+ # Returns the cached value, or computes it with the block, stores and
25
+ # returns it. nil results are cached too. force: true recomputes.
26
+ def fetch(key, expires_in: default_expires_in, force: false)
27
+ normalized = normalize(key)
28
+ unless force
29
+ value = read_entry(normalized)
30
+ return value unless value.equal?(MISSING)
31
+ end
32
+ raise ArgumentError, "fetch needs a block on a cache miss" unless block_given?
33
+
34
+ value = yield
35
+ write_entry(normalized, value, expires_in)
36
+ value
37
+ end
38
+
39
+ def read(key)
40
+ value = read_entry(normalize(key))
41
+ value.equal?(MISSING) ? nil : value
42
+ end
43
+
44
+ def write(key, value, expires_in: default_expires_in)
45
+ write_entry(normalize(key), value, expires_in)
46
+ value
47
+ end
48
+
49
+ def exist?(key) = !read_entry(normalize(key)).equal?(MISSING)
50
+ def delete(key) = delete_entry(normalize(key))
51
+
52
+ # Not atomic in the base class; stores override it where they can.
53
+ def increment(key, by = 1, expires_in: default_expires_in)
54
+ normalized = normalize(key)
55
+ current = read_entry(normalized)
56
+ value = (current.equal?(MISSING) ? 0 : Integer(current)) + by
57
+ write_entry(normalized, value, expires_in)
58
+ value
59
+ end
60
+
61
+ def decrement(key, by = 1, **) = increment(key, -by, **)
62
+
63
+ def normalize(key)
64
+ raw = key_part(key)
65
+ full = namespace ? "#{namespace}:#{raw}" : raw
66
+ full.bytesize > MAX_KEY ? "#{full[0, 100]}:sha256:#{Digest::SHA256.hexdigest(full)}" : full
67
+ end
68
+
69
+ private
70
+
71
+ def key_part(key)
72
+ case key
73
+ when Array then key.map { |part| key_part(part) }.join("/")
74
+ when String, Symbol, Numeric then key.to_s
75
+ else key.respond_to?(:cache_key) ? key.cache_key.to_s : key.to_s
76
+ end
77
+ end
78
+
79
+ def dump(value) = Marshal.dump(value)
80
+ def load(data) = Marshal.load(data) # rubocop:disable Security/MarshalLoad -- only data this process wrote
81
+
82
+ def expires_at(expires_in)
83
+ expires_in ? monotonic + Float(expires_in) : nil
84
+ end
85
+
86
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/core"
4
+
5
+ module GemStack
6
+ # Application caching:
7
+ #
8
+ # GemStack.cache.fetch("product:#{id}", expires_in: 300) { Product.find(id) }
9
+ # GemStack.cache.fetch([:stats, Date.today]) { expensive_report } # array keys
10
+ # GemStack.cache.increment("signups")
11
+ # GemStack.cache.delete("product:#{id}")
12
+ #
13
+ # Stores are swappable (config.cache.store): :memory (default), :null
14
+ # (default in test), :redis, or any object implementing the Store interface.
15
+ # Redis is never required.
16
+ module Cache
17
+ class Config < Settings
18
+ setting :store, default: -> { GemStack.env.test? ? :null : :memory }
19
+ # Prefix for every key ("shop:product:1"). Required for Redis#clear.
20
+ setting :namespace, default: -> { GemStack.config.name }
21
+ # Seconds; nil = no expiry unless given per call.
22
+ setting :default_expires_in, default: nil
23
+ # MemoryStore: maximum entries per process before the least recently used are evicted.
24
+ setting :max_entries, default: 10_000
25
+ setting :redis_url, default: -> { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
26
+ setting :redis_pool_size, default: -> { Integer(ENV.fetch("GEMSTACK_MAX_THREADS", 5)) }
27
+ end
28
+
29
+ class << self
30
+ def build(setting = GemStack.config.cache.store, config: GemStack.config.cache)
31
+ options = { namespace: config.namespace, default_expires_in: config.default_expires_in }
32
+ case setting
33
+ when :memory, "memory" then MemoryStore.new(max_entries: config.max_entries, **options)
34
+ when :null, "null" then NullStore.new(**options)
35
+ when :redis, "redis"
36
+ RedisStore.new(url: config.redis_url, pool_size: config.redis_pool_size, **options)
37
+ when Store then setting
38
+ else
39
+ raise ConfigurationError, "unknown cache store #{setting.inspect}" unless setting.respond_to?(:fetch)
40
+
41
+ setting
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ class << self
48
+ def cache
49
+ @cache ||= Cache.build
50
+ end
51
+
52
+ attr_writer :cache
53
+ end
54
+ end
55
+
56
+ require_relative "cache/store"
57
+ require_relative "cache/memory_store"
58
+ require_relative "cache/null_store"
59
+ require_relative "cache/redis_store"
60
+
61
+ GemStack::Config.namespace(:cache, GemStack::Cache::Config)
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ class CLI < Thor
5
+ # `gemstack add FEATURE` — opt-in modules, so apps that don't use them
6
+ # pay nothing: realtime, auth, storage.
7
+ class AddGenerator < Generator
8
+ FEATURES = %w[realtime auth storage].freeze
9
+
10
+ AUTH_ROUTES = <<~RUBY
11
+ # Authentication (gemstack add auth) — docs/authentication.md
12
+ post "/auth/signup", to: "registrations#create"
13
+ post "/auth/login", to: "sessions#create"
14
+ get "/auth/me", to: "sessions#show"
15
+ delete "/auth/logout", to: "sessions#destroy"
16
+ post "/auth/password/forgot", to: "password_resets#create"
17
+ post "/auth/password/reset", to: "password_resets#update"
18
+ post "/auth/email/resend", to: "email_verifications#create"
19
+ post "/auth/email/verify", to: "email_verifications#update"
20
+ get "/auth/tokens", to: "api_tokens#index"
21
+ post "/auth/tokens", to: "api_tokens#create"
22
+ delete "/auth/tokens/:id", to: "api_tokens#destroy"
23
+ RUBY
24
+
25
+ MAIL_ENV = <<~ENV
26
+
27
+ # Email. In development messages are saved to tmp/mail instead of being sent.
28
+ # SMTP_URL=smtp://user:password@smtp.example.com:587
29
+ # MAIL_FROM="My App <hello@example.com>"
30
+ # APP_URL=https://example.com # the frontend, for links in emails (default http://localhost:3000)
31
+ ENV
32
+
33
+ def initialize(feature, root:, output: $stdout, install: true)
34
+ super(output: output)
35
+ unless FEATURES.include?(feature)
36
+ raise Thor::Error,
37
+ "Unknown feature #{feature.inspect}. Available: #{FEATURES.join(", ")}"
38
+ end
39
+
40
+ @feature = feature
41
+ @root = root
42
+ @install = install
43
+ end
44
+
45
+ def run
46
+ public_send(:"add_#{@feature}")
47
+ self
48
+ end
49
+
50
+ def add_realtime
51
+ add_gem("gemstack-realtime")
52
+ template_files("realtime", override_root: @root).each do |rel, source|
53
+ next if rel.start_with?("frontend/") && !File.directory?(File.join(@root, "frontend"))
54
+
55
+ write(File.join(@root, rel), File.read(source))
56
+ end
57
+ add_test_support('require "gemstack/realtime/testing"', "GemStack::Realtime::Testing")
58
+ bundle_install
59
+ end
60
+
61
+ STORAGE_ENV = <<~ENV
62
+
63
+ # File storage. Development and tests store files under storage/ (git-ignored).
64
+ # STORAGE_SERVICE=s3 # production default
65
+ # S3_BUCKET=my-app-uploads
66
+ # AWS_REGION=eu-west-3
67
+ # AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... (or an instance role)
68
+ # S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com # S3-compatible services
69
+ ENV
70
+
71
+ def add_storage
72
+ enable_module("storage")
73
+ ensure_base_classes(@root, :serializer)
74
+ copy_feature_templates("storage")
75
+ add_routes(%(post "/uploads", to: "uploads#create" # direct uploads (gemstack add storage)\n),
76
+ marker: "uploads#create")
77
+ append_env_example(STORAGE_ENV, marker: "STORAGE_SERVICE")
78
+ append_line(".gitignore", "/storage/")
79
+ add_test_support('require "gemstack/storage/testing"', "GemStack::Storage::Testing")
80
+ status("note", "Gemfile", "add gem \"aws-sdk-s3\" for S3 in production (docs/storage.md)")
81
+ bundle_install
82
+ end
83
+
84
+ # For templates: whether `gemstack add auth` ran.
85
+ def auth? = Generator.uses?(@root, :auth)
86
+
87
+ def add_auth
88
+ require_database!("auth")
89
+ user_model = File.join(@root, "app/models/user.rb")
90
+ if File.file?(user_model) && !File.read(user_model).include?("GemStack::Auth::User")
91
+ raise Thor::Error, "app/models/user.rb already exists. Add `include GemStack::Auth::User` to it and " \
92
+ "see docs/authentication.md (\"Existing users table\") instead."
93
+ end
94
+
95
+ enable_module("jobs")
96
+ enable_module("mail")
97
+ add_gem("gemstack-auth")
98
+ JobGenerator.install_migration(@root, output: @output) # emails are sent from background jobs
99
+ ensure_base_classes(@root, :model, :serializer, :mailer)
100
+ copy_feature_templates("auth", skip_migration: "*_create_auth_tables.rb")
101
+ include_in_application_controller("GemStack::Auth::Controller")
102
+ add_routes(AUTH_ROUTES, marker: "/auth/login")
103
+ append_env_example(MAIL_ENV, marker: "SMTP_URL")
104
+ add_test_support('require "gemstack/mail/testing"', "GemStack::Mail::Testing")
105
+ add_test_support('require "gemstack/auth/testing"', "GemStack::Auth::Testing")
106
+ bundle_install
107
+ end
108
+
109
+ private
110
+
111
+ def frontend? = File.directory?(File.join(@root, "frontend"))
112
+
113
+ def require_database!(feature)
114
+ return if Generator.uses?(@root, :db)
115
+
116
+ raise Thor::Error, "#{feature} stores its data in the database, and this app was created without one: " \
117
+ "add `require \"gemstack/db\"` to config/app.rb, a config/database.yml and a driver gem " \
118
+ "first (docs/database.md)"
119
+ end
120
+
121
+ # Writes a feature's templates (frontend/ files only when the app has
122
+ # one). %timestamp% in a path becomes a fresh migration version, unless a
123
+ # migration matching skip_migration already exists.
124
+ def copy_feature_templates(name, skip_migration: nil)
125
+ has_migration = skip_migration && !Dir.glob(File.join(@root, "db/migrations", skip_migration)).empty?
126
+ template_files(name, override_root: @root).sort.each do |rel, source|
127
+ next if rel.start_with?("frontend/") && !frontend?
128
+
129
+ if rel.include?("%timestamp%")
130
+ next status("identical", rel, "migration already present") if has_migration
131
+
132
+ rel = rel.sub("%timestamp%", Generator.migration_timestamp(@root))
133
+ end
134
+ content = File.read(source)
135
+ content = render(content, source) if rel.end_with?(".tt")
136
+ write(File.join(@root, rel.delete_suffix(".tt")), content)
137
+ end
138
+ end
139
+
140
+ def include_in_application_controller(mixin)
141
+ path = File.join(@root, "app/controllers/application_controller.rb")
142
+ return status("skip", path, "not found — add `include #{mixin}` yourself") unless File.file?(path)
143
+
144
+ content = File.read(path)
145
+ return status("identical", path, mixin) if content.include?(mixin)
146
+
147
+ updated = content.sub(/^class ApplicationController < .*\n/) { |line| "#{line} include #{mixin}\n" }
148
+ return status("skip", path, "add `include #{mixin}` yourself") if updated == content
149
+
150
+ File.write(path, updated)
151
+ status("update", path, mixin)
152
+ end
153
+
154
+ def add_routes(block, marker:)
155
+ path = File.join(@root, "config/routes.rb")
156
+ content = File.file?(path) ? File.read(path) : ""
157
+ return status("identical", path, "routes") if content.include?(marker)
158
+ return status("skip", path, "no `GemStack.routes do` block — add the routes yourself") unless
159
+ content.match?(ControllerGenerator::ROUTES_BLOCK)
160
+
161
+ File.write(path, content.sub(ControllerGenerator::ROUTES_BLOCK) { |open| open + block.gsub(/^/, " ") })
162
+ status("route", path)
163
+ end
164
+
165
+ def append_line(file, line)
166
+ path = File.join(@root, file)
167
+ return unless File.file?(path)
168
+
169
+ content = File.read(path)
170
+ return if content.lines.map(&:strip).include?(line)
171
+
172
+ File.write(path, "#{content.rstrip}\n#{line}\n")
173
+ status("update", path, line)
174
+ end
175
+
176
+ def append_env_example(text, marker:)
177
+ path = File.join(@root, ".env.example")
178
+ return unless File.file?(path)
179
+
180
+ content = File.read(path)
181
+ return if content.include?(marker)
182
+
183
+ File.write(path, "#{content.rstrip}\n#{text}")
184
+ status("update", path)
185
+ end
186
+
187
+ # Switches a module on in config/app.rb: uncomments its require line, or
188
+ # adds one after `require "gemstack"`. Apps from before 0.3.0 that list
189
+ # the module's gem in the Gemfile already have it.
190
+ def enable_module(name)
191
+ path = File.join(@root, "config/app.rb")
192
+ return status("identical", path, "gemstack/#{name}") if Generator.uses?(@root, name)
193
+
194
+ content = File.file?(path) ? File.read(path) : ""
195
+ line = %(require "gemstack/#{name}")
196
+ updated = content.sub(%r{^#\s*require "gemstack/#{name}".*$}, line)
197
+ updated = content.sub(/^require "gemstack"\n/) { |gemstack| "#{gemstack}#{line}\n" } if updated == content
198
+ return status("skip", path, "add `#{line}` to config/app.rb yourself") if updated == content
199
+
200
+ File.write(path, updated)
201
+ status("update", path, line)
202
+ end
203
+
204
+ # Adds the gem inside the GemStack `path` block (checkout apps) or as a
205
+ # versioned dependency next to gem "gemstack".
206
+ def add_gem(name)
207
+ path = File.join(@root, "Gemfile")
208
+ content = File.read(path)
209
+ return status("identical", path, name) if content.match?(/^\s*gem "#{name}"/)
210
+
211
+ updated = content.match?(/^path ".*" do\n/) ? add_to_path_block(content, name) : add_versioned(content, name)
212
+ if updated == content
213
+ raise Thor::Error,
214
+ "couldn't find where to add #{name} in the Gemfile; add `gem \"#{name}\"` yourself"
215
+ end
216
+
217
+ File.write(path, updated)
218
+ status("gemfile", path, name)
219
+ end
220
+
221
+ # Keeps the block's gems in alphabetical order (Bundler/OrderedGems).
222
+ def add_to_path_block(content, name)
223
+ content.sub(/^(path ".*" do\n)((?: gem .*\n)*)/) do
224
+ opening = ::Regexp.last_match(1)
225
+ lines = ::Regexp.last_match(2) # before sort_by's regexes reset last_match
226
+ gems = (lines.lines + [" gem \"#{name}\"\n"]).sort_by { |line| line[/"([^"]+)"/, 1] }
227
+ opening + gems.join
228
+ end
229
+ end
230
+
231
+ def add_versioned(content, name)
232
+ content.sub(/^gem "gemstack",.*\n/) { |line| "#{line}gem \"#{name}\", \"~> #{GemStack::VERSION}\"\n" }
233
+ end
234
+
235
+ def add_test_support(require_line, mixin)
236
+ path = File.join(@root, "test/test_helper.rb")
237
+ return unless File.file?(path)
238
+
239
+ content = File.read(path)
240
+ return status("identical", path) if content.include?(mixin)
241
+
242
+ content = content.sub(%(require "gemstack/testing"\n)) { |line| "#{line}#{require_line}\n" }
243
+ content = "#{content.rstrip}\nGemStack::TestCase.include #{mixin}\n"
244
+ File.write(path, content)
245
+ status("update", path, mixin)
246
+ end
247
+
248
+ def bundle_install
249
+ return unless @install
250
+
251
+ status("run", "bundle install")
252
+ install = -> { system("bundle", "install", "--quiet", chdir: @root) }
253
+ ok = defined?(Bundler) ? Bundler.with_unbundled_env(&install) : install.call
254
+ status("warning", "bundle install failed — run it yourself") unless ok
255
+ end
256
+ end
257
+ end
258
+ end