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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "gemstack/core"
|
|
8
|
+
require "gemstack/http"
|
|
9
|
+
|
|
10
|
+
module GemStack
|
|
11
|
+
# File storage: files go from the browser straight to
|
|
12
|
+
# the storage service, never through a Ruby process.
|
|
13
|
+
#
|
|
14
|
+
# # POST /api/uploads → presigned upload for the browser
|
|
15
|
+
# upload = GemStack::Storage.direct_upload(filename: "cat.png", content_type: "image/png", byte_size: 52_000)
|
|
16
|
+
# # browser: PUT the file to upload[:url] with upload[:headers]
|
|
17
|
+
# # later: product.update(image_key: GemStack::Storage.key_for_signed_id(params[:image]))
|
|
18
|
+
# GemStack::Storage.url(product.image_key) # short-lived signed download URL
|
|
19
|
+
#
|
|
20
|
+
# Services: :disk (development and tests; files under storage/, served and
|
|
21
|
+
# accepted by a signed endpoint at /api/storage) and :s3 (S3, R2, MinIO…).
|
|
22
|
+
module Storage
|
|
23
|
+
class Config < Settings
|
|
24
|
+
# :disk, :s3, or a service object.
|
|
25
|
+
setting :service, default: -> { ENV.fetch("STORAGE_SERVICE", GemStack.env.production? ? "s3" : "disk").to_sym }
|
|
26
|
+
setting :root, default: -> { File.join(GemStack.config.root, "storage", GemStack.env.test? ? "test" : "") }
|
|
27
|
+
# Where the disk service's signed URLs point (handled by Storage::Endpoint).
|
|
28
|
+
setting :path, default: -> { "#{GemStack.config.http.api_path}/storage" }
|
|
29
|
+
setting :bucket, default: -> { ENV.fetch("S3_BUCKET", nil) }
|
|
30
|
+
setting :region, default: -> { ENV.fetch("AWS_REGION", ENV.fetch("S3_REGION", "us-east-1")) }
|
|
31
|
+
# For S3-compatible services: https://<account>.r2.cloudflarestorage.com, http://localhost:9000
|
|
32
|
+
setting :endpoint, default: -> { ENV.fetch("S3_ENDPOINT", nil) }
|
|
33
|
+
# Credentials come from the usual AWS chain (AWS_ACCESS_KEY_ID…, instance roles) unless set.
|
|
34
|
+
setting :s3_options, default: {}
|
|
35
|
+
setting :url_expires_in, default: 300
|
|
36
|
+
setting :max_upload_size, default: 10 * 1024 * 1024
|
|
37
|
+
# MIME types accepted by direct_upload ("image/*" style wildcards).
|
|
38
|
+
# SVG and HTML are excluded on purpose: they can carry scripts.
|
|
39
|
+
setting :allowed_content_types, default: %w[image/png image/jpeg image/gif image/webp image/avif application/pdf]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class InvalidUpload < ValidationError; end
|
|
43
|
+
|
|
44
|
+
# Served inline by browsers; everything else is sent as an attachment.
|
|
45
|
+
INLINE_TYPES = %w[image/png image/jpeg image/gif image/webp image/avif application/pdf text/plain].freeze
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def config = GemStack.config.storage
|
|
49
|
+
|
|
50
|
+
def service
|
|
51
|
+
@service ||= build_service(config.service)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_writer :service
|
|
55
|
+
|
|
56
|
+
def reset! = @service = nil
|
|
57
|
+
|
|
58
|
+
# Validates an upload request and returns what the browser needs:
|
|
59
|
+
# { key:, signed_id:, url:, method: "PUT", headers: {…} }
|
|
60
|
+
def direct_upload(filename:, content_type:, byte_size:, prefix: "uploads")
|
|
61
|
+
content_type = content_type.to_s.downcase.split(";").first.to_s.strip
|
|
62
|
+
byte_size = Integer(byte_size, exception: false)
|
|
63
|
+
validate_upload!(content_type, byte_size)
|
|
64
|
+
|
|
65
|
+
key = generate_key(prefix, filename, content_type)
|
|
66
|
+
upload = service.presigned_upload(key, content_type: content_type, byte_size: byte_size,
|
|
67
|
+
expires_in: config.url_expires_in)
|
|
68
|
+
{ key: key, signed_id: sign(key, purpose: "upload"), **upload }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The key behind a signed_id from direct_upload; 422 when tampered with
|
|
72
|
+
# (so clients can't point records at other people's files).
|
|
73
|
+
def key_for_signed_id(signed_id)
|
|
74
|
+
verify(signed_id, purpose: "upload") ||
|
|
75
|
+
raise(InvalidUpload.new("Invalid upload reference", errors: { file: ["is invalid"] }))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def url(key, expires_in: config.url_expires_in, disposition: nil, filename: nil)
|
|
79
|
+
service.url(key, expires_in: expires_in, disposition: disposition || disposition_for(key), filename: filename)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def upload(key, io, content_type:) = service.upload(key, io, content_type: content_type)
|
|
83
|
+
def download(key) = service.download(key)
|
|
84
|
+
def delete(key) = service.delete(key)
|
|
85
|
+
def exist?(key) = service.exist?(key)
|
|
86
|
+
|
|
87
|
+
def allowed_content_type?(type)
|
|
88
|
+
Array(config.allowed_content_types).any? do |pattern|
|
|
89
|
+
pattern.end_with?("/*") ? type.start_with?(pattern.delete_suffix("*")) : type == pattern
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Tamper-proof, expiring tokens (HMAC with GemStack.key_for("storage")).
|
|
94
|
+
def sign(value, purpose:, expires_in: nil)
|
|
95
|
+
payload = { "v" => value, "p" => purpose }
|
|
96
|
+
payload["e"] = Time.now.to_i + expires_in if expires_in
|
|
97
|
+
data = [JSON.generate(payload)].pack("m0").tr("+/", "-_").delete("=")
|
|
98
|
+
"#{data}--#{digest(data)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def verify(token, purpose:)
|
|
102
|
+
data, mac = token.to_s.split("--", 2)
|
|
103
|
+
return nil unless data && mac && OpenSSL.fixed_length_secure_compare(digest(data), mac)
|
|
104
|
+
|
|
105
|
+
payload = JSON.parse(data.tr("-_", "+/").ljust((data.length + 3) & ~3, "=").unpack1("m0"))
|
|
106
|
+
return nil unless payload["p"] == purpose
|
|
107
|
+
return nil if payload["e"] && payload["e"] < Time.now.to_i
|
|
108
|
+
|
|
109
|
+
payload["v"]
|
|
110
|
+
rescue ArgumentError, JSON::ParserError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Keys are ours, not the client's: uploads/2026/09/<uuid>/<clean name>.
|
|
115
|
+
# The extension always matches the validated content type, so a file
|
|
116
|
+
# declared as image/png can't be served as HTML because it's named x.html.
|
|
117
|
+
def generate_key(prefix, filename, content_type)
|
|
118
|
+
base = File.basename(filename.to_s, ".*").unicode_normalize(:nfkc).gsub(/[^\w-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
119
|
+
base = "file" if base.empty?
|
|
120
|
+
[prefix, Time.now.utc.strftime("%Y/%m"), SecureRandom.uuid, "#{base[0, 100]}#{extension_for(content_type)}"]
|
|
121
|
+
.reject(&:empty?).join("/")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
EXTENSIONS = { "image/jpeg" => ".jpg", "image/png" => ".png", "image/gif" => ".gif", "image/webp" => ".webp",
|
|
125
|
+
"image/avif" => ".avif", "application/pdf" => ".pdf", "text/plain" => ".txt",
|
|
126
|
+
"text/csv" => ".csv" }.freeze
|
|
127
|
+
|
|
128
|
+
def extension_for(content_type) = EXTENSIONS[content_type] || Rack::Mime::MIME_TYPES.key(content_type) || ".bin"
|
|
129
|
+
|
|
130
|
+
def content_type_for(key) = Rack::Mime.mime_type(File.extname(key.to_s), "application/octet-stream")
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def build_service(service)
|
|
135
|
+
case service
|
|
136
|
+
when :disk, "disk" then Services::Disk.new(root: config.root, path: config.path)
|
|
137
|
+
when :s3, "s3"
|
|
138
|
+
require_relative "storage/services/s3"
|
|
139
|
+
Services::S3.new(bucket: config.bucket, region: config.region, endpoint: config.endpoint,
|
|
140
|
+
**config.s3_options)
|
|
141
|
+
else
|
|
142
|
+
unless service.respond_to?(:presigned_upload)
|
|
143
|
+
raise ConfigurationError, "unknown config.storage.service #{service.inspect} (use :disk, :s3 or an object)"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
service
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_upload!(content_type, byte_size)
|
|
151
|
+
errors = {}
|
|
152
|
+
errors[:content_type] = ["#{content_type.inspect} is not allowed"] unless allowed_content_type?(content_type)
|
|
153
|
+
max = config.max_upload_size
|
|
154
|
+
if byte_size.nil? || byte_size <= 0
|
|
155
|
+
errors[:byte_size] = ["must be a positive number of bytes"]
|
|
156
|
+
elsif byte_size > max
|
|
157
|
+
errors[:byte_size] = ["is too large (maximum #{max / 1024 / 1024} MB)"]
|
|
158
|
+
end
|
|
159
|
+
raise InvalidUpload.new("Upload refused", errors: errors) unless errors.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Browsers show images and PDFs inline; anything else downloads.
|
|
163
|
+
def disposition_for(key) = INLINE_TYPES.include?(content_type_for(key)) ? "inline" : "attachment"
|
|
164
|
+
|
|
165
|
+
def digest(data) = OpenSSL::HMAC.hexdigest("SHA256", GemStack.key_for("storage"), data)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
require_relative "storage/services/disk"
|
|
171
|
+
require_relative "storage/endpoint"
|
|
172
|
+
|
|
173
|
+
GemStack::Config.namespace(:storage, GemStack::Storage::Config)
|
|
174
|
+
|
|
175
|
+
GemStack::Plugins.register(:storage) do |app|
|
|
176
|
+
next unless app.respond_to?(:config)
|
|
177
|
+
|
|
178
|
+
stack = app.config.http.middleware
|
|
179
|
+
# Before BodyLimit: signed uploads carry their own size limit.
|
|
180
|
+
unless stack.include?(GemStack::Storage::Endpoint)
|
|
181
|
+
stack.insert_before(GemStack::HTTP::Middleware::BodyLimit, GemStack::Storage::Endpoint)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
# The type system shared by model fields, request schemas, serializers and
|
|
9
|
+
# the TypeScript/OpenAPI generators — one name per concept everywhere:
|
|
10
|
+
#
|
|
11
|
+
# field :price, :decimal (model)
|
|
12
|
+
# required :price, :decimal (request schema)
|
|
13
|
+
# attribute :price, :decimal (serializer)
|
|
14
|
+
# price: string (generated TypeScript)
|
|
15
|
+
#
|
|
16
|
+
# Each type knows how to coerce input (strings from forms and query strings,
|
|
17
|
+
# JSON values), how to dump values into JSON, and its TypeScript / OpenAPI
|
|
18
|
+
# representation. Register your own with Types.register.
|
|
19
|
+
module Types
|
|
20
|
+
# Raised by a coercer when a value can't be converted; the message is the
|
|
21
|
+
# client-facing validation message ("must be an integer").
|
|
22
|
+
class CoercionError < StandardError; end
|
|
23
|
+
|
|
24
|
+
Type = Struct.new(:name, :ts, :openapi, :coercer, :dumper, keyword_init: true) do
|
|
25
|
+
def coerce(value) = coercer ? coercer.call(value) : value
|
|
26
|
+
def dump(value) = value.nil? || dumper.nil? ? value : dumper.call(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
INTEGER = /\A[-+]?\d+\z/
|
|
30
|
+
NUMBER = /\A[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?\z/
|
|
31
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
|
|
32
|
+
TRUE_VALUES = [true, 1, "1", "true", "TRUE", "True", "on", "yes"].freeze
|
|
33
|
+
FALSE_VALUES = [false, 0, "0", "false", "FALSE", "False", "off", "no"].freeze
|
|
34
|
+
|
|
35
|
+
CLASS_ALIASES = {
|
|
36
|
+
String => :string, Integer => :integer, Float => :float, BigDecimal => :decimal,
|
|
37
|
+
TrueClass => :boolean, FalseClass => :boolean, Date => :date, Time => :datetime,
|
|
38
|
+
DateTime => :datetime, Hash => :json
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
@registry = {}
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def register(name, ts:, openapi:, coerce: nil, dump: nil)
|
|
45
|
+
@registry[name.to_sym] = Type.new(name: name.to_sym, ts: ts, openapi: openapi.freeze,
|
|
46
|
+
coercer: coerce, dumper: dump).freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Accepts a type name (:string) or a Ruby class (String, Integer, ...).
|
|
50
|
+
def fetch(name)
|
|
51
|
+
key = CLASS_ALIASES.fetch(name, name)
|
|
52
|
+
@registry.fetch(key.to_sym) do
|
|
53
|
+
raise ArgumentError, "unknown type #{name.inspect}; known types: #{@registry.keys.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def type?(name)
|
|
58
|
+
CLASS_ALIASES.key?(name) || ((name.is_a?(Symbol) || name.is_a?(String)) && @registry.key?(name.to_sym))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def names = @registry.keys
|
|
62
|
+
|
|
63
|
+
def fail!(message) = raise(CoercionError, message)
|
|
64
|
+
|
|
65
|
+
def to_integer(value)
|
|
66
|
+
case value
|
|
67
|
+
when Integer then value
|
|
68
|
+
when Float then value == value.floor ? value.to_i : fail!("must be an integer")
|
|
69
|
+
when String then value.strip.match?(INTEGER) ? Integer(value.strip, 10) : fail!("must be an integer")
|
|
70
|
+
else fail!("must be an integer")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_float(value)
|
|
75
|
+
case value
|
|
76
|
+
when Float then value
|
|
77
|
+
when Numeric then value.to_f
|
|
78
|
+
when String then value.strip.match?(NUMBER) ? Float(value.strip) : fail!("must be a number")
|
|
79
|
+
else fail!("must be a number")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_decimal(value)
|
|
84
|
+
case value
|
|
85
|
+
when BigDecimal then value
|
|
86
|
+
when Integer then BigDecimal(value)
|
|
87
|
+
when Float then BigDecimal(value.to_s)
|
|
88
|
+
when String then value.strip.match?(NUMBER) ? BigDecimal(value.strip) : fail!("must be a decimal number")
|
|
89
|
+
else fail!("must be a decimal number")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_boolean(value)
|
|
94
|
+
return true if TRUE_VALUES.include?(value)
|
|
95
|
+
return false if FALSE_VALUES.include?(value)
|
|
96
|
+
|
|
97
|
+
fail!("must be true or false")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_date(value)
|
|
101
|
+
case value
|
|
102
|
+
when DateTime, Time then value.to_date
|
|
103
|
+
when Date then value
|
|
104
|
+
when String then Date.iso8601(value.strip)
|
|
105
|
+
else fail!("must be a date (YYYY-MM-DD)")
|
|
106
|
+
end
|
|
107
|
+
rescue ArgumentError # includes Date::Error
|
|
108
|
+
fail!("must be a date (YYYY-MM-DD)")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_datetime(value)
|
|
112
|
+
case value
|
|
113
|
+
when Time then value
|
|
114
|
+
when DateTime then value.to_time
|
|
115
|
+
when String then parse_time(value.strip)
|
|
116
|
+
else fail!("must be a date-time (ISO 8601)")
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# ISO 8601 with a zone, or a zone-less "YYYY-MM-DDTHH:MM[:SS]" (as sent
|
|
123
|
+
# by <input type="datetime-local">), which is read as UTC.
|
|
124
|
+
def parse_time(string)
|
|
125
|
+
Time.iso8601(string)
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
match = string.match(/\A(\d{4})-(\d\d)-(\d\d)[T ](\d\d):(\d\d)(?::(\d\d))?\z/)
|
|
128
|
+
fail!("must be a date-time (ISO 8601)") unless match
|
|
129
|
+
Time.utc(*match.captures.compact.map(&:to_i))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
string = lambda do |value|
|
|
134
|
+
case value
|
|
135
|
+
when String then value
|
|
136
|
+
when Symbol, Numeric then value.to_s
|
|
137
|
+
else fail!("must be a string")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
iso_time = ->(value) { (value.respond_to?(:to_time) ? value.to_time : value).utc.iso8601(3) }
|
|
141
|
+
|
|
142
|
+
register :string, ts: "string", openapi: { type: "string" }, coerce: string
|
|
143
|
+
register :text, ts: "string", openapi: { type: "string" }, coerce: string
|
|
144
|
+
register :integer, ts: "number", openapi: { type: "integer" }, coerce: method(:to_integer)
|
|
145
|
+
register :bigint, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
|
|
146
|
+
register :references, ts: "number", openapi: { type: "integer", format: "int64" }, coerce: method(:to_integer)
|
|
147
|
+
register :float, ts: "number", openapi: { type: "number" }, coerce: method(:to_float)
|
|
148
|
+
# Decimals travel as strings so no precision is lost in JavaScript.
|
|
149
|
+
register :decimal, ts: "string", openapi: { type: "string", format: "decimal" },
|
|
150
|
+
coerce: method(:to_decimal),
|
|
151
|
+
dump: ->(value) { (value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)).to_s("F") }
|
|
152
|
+
register :boolean, ts: "boolean", openapi: { type: "boolean" }, coerce: method(:to_boolean)
|
|
153
|
+
register :date, ts: "string", openapi: { type: "string", format: "date" },
|
|
154
|
+
coerce: method(:to_date), dump: lambda(&:iso8601)
|
|
155
|
+
register :datetime, ts: "string", openapi: { type: "string", format: "date-time" },
|
|
156
|
+
coerce: method(:to_datetime), dump: iso_time
|
|
157
|
+
register :uuid, ts: "string", openapi: { type: "string", format: "uuid" },
|
|
158
|
+
coerce: lambda { |value|
|
|
159
|
+
value.is_a?(String) && value.match?(UUID) ? value.downcase : fail!("must be a UUID")
|
|
160
|
+
}
|
|
161
|
+
register :json, ts: "unknown", openapi: {}
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/gemstack.rb
CHANGED
|
@@ -33,7 +33,7 @@ module GemStack
|
|
|
33
33
|
# class ApplicationController < GemStack::Controller
|
|
34
34
|
Controller = HTTP::Controller
|
|
35
35
|
Params = HTTP::Params
|
|
36
|
-
# GemStack::Schema, GemStack::Serializer and GemStack::Types come from gemstack
|
|
36
|
+
# GemStack::Schema, GemStack::Serializer and GemStack::Types come from gemstack/schema.
|
|
37
37
|
|
|
38
38
|
class << self
|
|
39
39
|
def application
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
<% if gemstack_path -%>
|
|
6
|
+
# GemStack from a local checkout.
|
|
7
|
+
path "<%= gemstack_path %>" do
|
|
8
|
+
gem "gemstack"
|
|
9
|
+
end
|
|
10
|
+
<% else -%>
|
|
11
|
+
gem "gemstack", "~> <%= version %>" # the framework: config/app.rb chooses the modules it loads
|
|
12
|
+
<% end -%>
|
|
13
|
+
gem "puma", ">= 6.4"
|
|
14
|
+
<% if database? -%>
|
|
15
|
+
gem "<%= driver_gem %>", "<%= driver_version %>" # the <%= database_adapter %> driver (config/database.yml)
|
|
16
|
+
<% end -%>
|
|
17
|
+
|
|
18
|
+
# Any gem works here as usual, e.g.:
|
|
19
|
+
# gem "brotli" # Brotli compression (gzip is used without it)
|
|
20
|
+
# gem "redis-client" # shared cache: config.cache.store = :redis
|
|
21
|
+
# gem "stripe"
|
|
22
|
+
|
|
23
|
+
group :development, :test do
|
|
24
|
+
gem "irb" # `gemstack console` (a bundled gem, not a default one, in newer Rubies)
|
|
25
|
+
gem "minitest", "~> 5.25"
|
|
26
|
+
gem "rack-test", "~> 2.2"
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# <%= module_name %>
|
|
2
|
+
|
|
3
|
+
A [GemStack](https://github.com/gemstack/gemstack) application: a Ruby API with a
|
|
4
|
+
Next.js + TypeScript frontend, served from one origin.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
gemstack dev # http://localhost:3000 (/api/* → Ruby, everything else → Next.js)
|
|
8
|
+
gemstack routes # list API routes
|
|
9
|
+
gemstack test # run the Ruby tests
|
|
10
|
+
gemstack console # IRB with the app loaded
|
|
11
|
+
gemstack generate controller Status show
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
| Path | What lives there |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `config/app.rb` | configuration (`GemStack.configure`) |
|
|
19
|
+
| `config/routes.rb` | API routes, relative to `/api` |
|
|
20
|
+
| `app/` | autoloaded code: `controllers/`, and any folder you add (`services/`, ...) |
|
|
21
|
+
| `test/` | Minitest + rack-test (`GemStack::TestCase`) |
|
|
22
|
+
| `frontend/` | the Next.js app; `lib/gemstack/client.ts` talks to the API |
|
|
23
|
+
|
|
24
|
+
## Production
|
|
25
|
+
|
|
26
|
+
Run the API with `gemstack server` (Puma, `config/puma.rb`) and the frontend
|
|
27
|
+
with `npm run build && npm start` in `frontend/`. Either route `/api/*` to the
|
|
28
|
+
API with your reverse proxy, or set `GEMSTACK_API_URL` for the Next.js process
|
|
29
|
+
and it will proxy `/api/*` itself.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The base class for this app's background jobs (docs/background-jobs.md):
|
|
4
|
+
# retry and discard rules declared here apply to every job.
|
|
5
|
+
class ApplicationJob < GemStack::Job
|
|
6
|
+
# retry_on Net::ReadTimeout, attempts: 5, wait: 30 # seconds, :exponential or ->(attempt) { … }
|
|
7
|
+
# discard_on GemStack::NotFound # e.g. the record was deleted before the job ran
|
|
8
|
+
# queue :default
|
|
9
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The base class for this app's mailers (docs/mail.md). Templates live in
|
|
4
|
+
# app/mailers/templates/<mailer>/<action>.{text,html}.erb.
|
|
5
|
+
class ApplicationMailer < GemStack::Mailer
|
|
6
|
+
# default from: "My App <hello@example.com>" # otherwise config.mail.default_from (MAIL_FROM)
|
|
7
|
+
# default reply_to: "support@example.com"
|
|
8
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The base class for this app's models — add plugins, validations and helpers
|
|
4
|
+
# every model should share here. `class Product < ApplicationModel` maps to the
|
|
5
|
+
# products table (docs/models.md).
|
|
6
|
+
#
|
|
7
|
+
# Created with Class.new on purpose: `class ApplicationModel < GemStack::Model`
|
|
8
|
+
# would make Sequel look for an "application_models" table.
|
|
9
|
+
ApplicationModel = Class.new(GemStack::Model) # rubocop:disable Style/EmptyClassDefinition
|
|
10
|
+
|
|
11
|
+
class ApplicationModel
|
|
12
|
+
# Sequel plugins for every model, e.g.:
|
|
13
|
+
# plugin :dirty # previous values after an update (column_changes)
|
|
14
|
+
|
|
15
|
+
# Shared query helpers:
|
|
16
|
+
# dataset_module do
|
|
17
|
+
# def recent = order(Sequel.desc(:created_at))
|
|
18
|
+
# end
|
|
19
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "gemstack"
|
|
5
|
+
<% if database? -%>
|
|
6
|
+
require "gemstack/db" # models and migrations — the connection is in config/database.yml
|
|
7
|
+
<% end -%>
|
|
8
|
+
require "gemstack/jobs" # background jobs (app/jobs)
|
|
9
|
+
require "gemstack/mail" # mailers (app/mailers)
|
|
10
|
+
# require "gemstack/storage" # file uploads — `gemstack add storage` turns this on
|
|
11
|
+
|
|
12
|
+
# Sets the application root and loads .env files (development/test).
|
|
13
|
+
GemStack.setup(root: File.expand_path("..", __dir__))
|
|
14
|
+
|
|
15
|
+
# Require the gems listed in the Gemfile for the current environment.
|
|
16
|
+
Bundler.require(:default, GemStack.env.to_sym)
|
|
17
|
+
|
|
18
|
+
GemStack.configure do |config|
|
|
19
|
+
config.name = "<%= name %>"
|
|
20
|
+
|
|
21
|
+
# Settings for every environment. Everything below is optional: each line
|
|
22
|
+
# shows the default — uncomment one to change it. Per-environment settings go
|
|
23
|
+
# in config/environments/*.rb. All settings: docs/configuration.md.
|
|
24
|
+
|
|
25
|
+
# ── HTTP ────────────────────────────────────────────────────────────────
|
|
26
|
+
# config.http.api_path = "/api" # where the API lives (routes are relative to it)
|
|
27
|
+
# config.http.max_body_size = 10 * 1024 * 1024 # bytes; larger requests get 413
|
|
28
|
+
# config.http.json = :json # or :oj (add gem "oj"), or your own codec
|
|
29
|
+
# config.http.cors.origins = [] # only for frontends on another domain
|
|
30
|
+
# config.http.middleware.use MyMiddleware # add / insert_before / swap / delete
|
|
31
|
+
|
|
32
|
+
# ── Pagination: paginate(...) in controllers, ?page=2&per_page=50 (docs/pagination.md) ──
|
|
33
|
+
# config.http.pagination.per_page = 25 # records per page when the client doesn't say
|
|
34
|
+
# config.http.pagination.max_per_page = 100 # the most a client may ask for with ?per_page=
|
|
35
|
+
|
|
36
|
+
# ── Responses ───────────────────────────────────────────────────────────
|
|
37
|
+
# config.http.compression.enabled = true # gzip/Brotli responses over 1 KB (add gem "brotli" for br)
|
|
38
|
+
# config.http.etags = true # ETag + 304 Not Modified for GET responses
|
|
39
|
+
|
|
40
|
+
# ── Logging ─────────────────────────────────────────────────────────────
|
|
41
|
+
# config.logger.level = :debug # :info in production (or GEMSTACK_LOG_LEVEL)
|
|
42
|
+
# config.filter_parameters += %w[iban] # masked in logs, next to password, token, secret…
|
|
43
|
+
<% if database? -%>
|
|
44
|
+
|
|
45
|
+
# ── Database — the connection is in config/database.yml (DATABASE_URL overrides it) ──
|
|
46
|
+
# config.db.pool_size = 5 # default: GEMSTACK_MAX_THREADS (one per Puma thread)
|
|
47
|
+
# config.db.statement_timeout = 5_000 # ms
|
|
48
|
+
<% end -%>
|
|
49
|
+
|
|
50
|
+
# ── Cache (GemStack.cache) ──────────────────────────────────────────────
|
|
51
|
+
# config.cache.store = :memory # :redis to share it between processes (gem "redis-client")
|
|
52
|
+
# config.cache.default_expires_in = nil # seconds; nil = until evicted
|
|
53
|
+
|
|
54
|
+
# ── Background jobs ─────────────────────────────────────────────────────
|
|
55
|
+
# config.jobs.default_queue = "default"
|
|
56
|
+
# config.jobs.default_max_attempts = 10 # then the job is kept as failed
|
|
57
|
+
|
|
58
|
+
# ── Mail ────────────────────────────────────────────────────────────────
|
|
59
|
+
# config.mail.default_from = "<%= name %> <no-reply@localhost>" # or MAIL_FROM
|
|
60
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Database connection (docs/database.md) — the same format as Rails' database.yml.
|
|
2
|
+
# DATABASE_URL (TEST_DATABASE_URL in tests) overrides this file when set, which
|
|
3
|
+
# is what most hosting platforms provide. Adapters: sqlite3, postgresql, mysql2,
|
|
4
|
+
# trilogy (add the matching gem: sqlite3, pg, mysql2, trilogy).
|
|
5
|
+
default: &default
|
|
6
|
+
<% if database_adapter == "sqlite3" -%>
|
|
7
|
+
adapter: sqlite3
|
|
8
|
+
pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
|
|
9
|
+
|
|
10
|
+
development:
|
|
11
|
+
<<: *default
|
|
12
|
+
database: db/development.sqlite3
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
<<: *default
|
|
16
|
+
database: db/test.sqlite3
|
|
17
|
+
|
|
18
|
+
# Keep the file on persistent storage (a volume); or set DATABASE_URL=sqlite3:/data/app.sqlite3.
|
|
19
|
+
production:
|
|
20
|
+
<<: *default
|
|
21
|
+
database: db/production.sqlite3
|
|
22
|
+
<% elsif database_adapter == "postgresql" -%>
|
|
23
|
+
adapter: postgresql
|
|
24
|
+
pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
|
|
25
|
+
# Without host/username, the local server is used as your OS user.
|
|
26
|
+
# host: localhost
|
|
27
|
+
# username: <%= database_name %>
|
|
28
|
+
# password: <%%= ENV["DATABASE_PASSWORD"] %>
|
|
29
|
+
|
|
30
|
+
development:
|
|
31
|
+
<<: *default
|
|
32
|
+
database: <%= database_name %>_development
|
|
33
|
+
|
|
34
|
+
test:
|
|
35
|
+
<<: *default
|
|
36
|
+
database: <%= database_name %>_test
|
|
37
|
+
|
|
38
|
+
# In production, DATABASE_URL (postgres://user:password@host:5432/name) usually replaces this.
|
|
39
|
+
production:
|
|
40
|
+
<<: *default
|
|
41
|
+
database: <%= database_name %>_production
|
|
42
|
+
<% else -%>
|
|
43
|
+
adapter: <%= database_adapter %>
|
|
44
|
+
pool: <%%= ENV.fetch("GEMSTACK_MAX_THREADS", 5) %>
|
|
45
|
+
host: <%%= ENV.fetch("DATABASE_HOST", "127.0.0.1") %>
|
|
46
|
+
username: root
|
|
47
|
+
password: <%%= ENV["DATABASE_PASSWORD"] %>
|
|
48
|
+
|
|
49
|
+
development:
|
|
50
|
+
<<: *default
|
|
51
|
+
database: <%= database_name %>_development
|
|
52
|
+
|
|
53
|
+
test:
|
|
54
|
+
<<: *default
|
|
55
|
+
database: <%= database_name %>_test
|
|
56
|
+
|
|
57
|
+
# In production, DATABASE_URL (<%= database_adapter %>://user:password@host:3306/name) usually replaces this.
|
|
58
|
+
production:
|
|
59
|
+
<<: *default
|
|
60
|
+
database: <%= database_name %>_production
|
|
61
|
+
<% end -%>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Development settings: applied after config/app.rb when GEMSTACK_ENV=development
|
|
4
|
+
# (the default). Each line shows the default value — uncomment one to change it.
|
|
5
|
+
# All settings: docs/configuration.md.
|
|
6
|
+
GemStack.configure do |config|
|
|
7
|
+
# ── Code ────────────────────────────────────────────────────────────────
|
|
8
|
+
# config.reload_code = true # reload app/ and config/routes.rb when they change
|
|
9
|
+
# config.eager_load = false # load app/ lazily, for a faster boot
|
|
10
|
+
# config.jit = nil # :yjit or :zjit to try Ruby's JIT locally
|
|
11
|
+
|
|
12
|
+
# ── Logging ─────────────────────────────────────────────────────────────
|
|
13
|
+
# config.logger.level = :debug # :debug, :info, :warn, :error (or GEMSTACK_LOG_LEVEL)
|
|
14
|
+
# config.logger.format = :pretty # readable lines; :json to see what production logs
|
|
15
|
+
|
|
16
|
+
# ── HTTP ────────────────────────────────────────────────────────────────
|
|
17
|
+
# config.http.show_exceptions = true # 500s carry the exception; browsers get an error page
|
|
18
|
+
# config.contract.docs = true # interactive API docs at /api/docs
|
|
19
|
+
|
|
20
|
+
# ── gemstack dev ────────────────────────────────────────────────────────
|
|
21
|
+
# config.dev.port = 3000 # the one public port (or PORT)
|
|
22
|
+
# config.dev.jobs_command = %w[bundle exec gemstack jobs] # nil: don't start a jobs worker
|
|
23
|
+
<% if database? -%>
|
|
24
|
+
|
|
25
|
+
# ── Database (connection: config/database.yml) ──────────────────────────
|
|
26
|
+
# config.db.log_queries = true # every SQL statement at DEBUG
|
|
27
|
+
# config.db.slow_query_ms = 500 # slower queries are logged at WARN
|
|
28
|
+
<% end -%>
|
|
29
|
+
|
|
30
|
+
# ── Cache ───────────────────────────────────────────────────────────────
|
|
31
|
+
# config.cache.store = :memory # :null to switch caching off, :redis to share it
|
|
32
|
+
|
|
33
|
+
# ── Mail ────────────────────────────────────────────────────────────────
|
|
34
|
+
# config.mail.delivery = :log # saved to tmp/mail (.eml + .html); :smtp really sends
|
|
35
|
+
end
|