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
data/lib/gemstack/db.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel"
|
|
4
|
+
require "json"
|
|
5
|
+
require "gemstack/core"
|
|
6
|
+
require "gemstack/schema"
|
|
7
|
+
|
|
8
|
+
require_relative "db/json_compat"
|
|
9
|
+
require_relative "db/configuration"
|
|
10
|
+
require_relative "db/schema_types"
|
|
11
|
+
|
|
12
|
+
module GemStack
|
|
13
|
+
# SQL databases through Sequel: PostgreSQL, MySQL
|
|
14
|
+
# (mysql2 or trilogy) and SQLite, configured by config/database.yml or
|
|
15
|
+
# DATABASE_URL. Load it with `require "gemstack/db"` in config/app.rb and add the driver gem
|
|
16
|
+
# (pg, mysql2, trilogy or sqlite3) to the Gemfile — `gemstack new` does.
|
|
17
|
+
#
|
|
18
|
+
# GemStack.db[:products].where(active: true).count # the Sequel::Database
|
|
19
|
+
# GemStack.transaction { order.save; payment.save }
|
|
20
|
+
module DB
|
|
21
|
+
class Config < Settings
|
|
22
|
+
# An explicit database URL; wins over DATABASE_URL and config/database.yml
|
|
23
|
+
# (see DB::Configuration). Test uses TEST_DATABASE_URL, never DATABASE_URL,
|
|
24
|
+
# so a development database from .env can't be wiped by the test suite.
|
|
25
|
+
setting :url, default: nil
|
|
26
|
+
# One connection per server thread by default (database.yml `pool:` wins).
|
|
27
|
+
setting :pool_size, default: lambda {
|
|
28
|
+
Integer(ENV.fetch("GEMSTACK_DB_POOL") { ENV.fetch("GEMSTACK_MAX_THREADS", 5) })
|
|
29
|
+
}
|
|
30
|
+
setting :pool_timeout, default: 5
|
|
31
|
+
setting :migrations_path, default: "db/migrations"
|
|
32
|
+
setting :seeds_path, default: "db/seeds.rb"
|
|
33
|
+
setting :log_queries, default: -> { GemStack.env.development? }
|
|
34
|
+
# Queries slower than this are logged at WARN in every environment.
|
|
35
|
+
setting :slow_query_ms, default: 500
|
|
36
|
+
# Server-side statement timeout in ms (nil = the database's default).
|
|
37
|
+
# PostgreSQL: statement_timeout; MySQL: max_execution_time (SELECTs); SQLite: none.
|
|
38
|
+
setting :statement_timeout, default: nil
|
|
39
|
+
# Sequel extensions to load; nil = pg_json + pg_array on PostgreSQL, none elsewhere.
|
|
40
|
+
setting :extensions, default: nil
|
|
41
|
+
# Extra options passed to Sequel.connect.
|
|
42
|
+
setting :options, default: {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class RecordNotFound < NotFound; end
|
|
46
|
+
|
|
47
|
+
# Stored and compared in UTC on every adapter (MySQL DATETIME and SQLite
|
|
48
|
+
# text have no time zone); PostgreSQL timestamptz is unaffected.
|
|
49
|
+
Sequel.database_timezone = :utc
|
|
50
|
+
|
|
51
|
+
# Adapts Sequel's logging to the GemStack logger: SQL at DEBUG only when
|
|
52
|
+
# config.db.log_queries is on, slow queries at WARN, and failures of the
|
|
53
|
+
# existence probes Sequel runs on purpose (table_exists?, model setup
|
|
54
|
+
# before a migration) are not reported as errors.
|
|
55
|
+
class QueryLogger
|
|
56
|
+
PROBE = /: SELECT (?:NULL AS [`"']nil[`"'] FROM \S+ LIMIT 1|\* FROM \S+ LIMIT 0)\z/
|
|
57
|
+
|
|
58
|
+
def initialize(logger, queries:)
|
|
59
|
+
@logger = logger
|
|
60
|
+
@queries = queries
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def debug(message) = (@logger.debug(message) if @queries)
|
|
64
|
+
def info(message) = @logger.info(message)
|
|
65
|
+
def warn(message) = @logger.warn(message)
|
|
66
|
+
def error(message) = (@logger.error(message) unless PROBE.match?(message.to_s))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@mutex = Mutex.new
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def config = GemStack.config.db
|
|
73
|
+
|
|
74
|
+
def connection
|
|
75
|
+
@connection || @mutex.synchronize { @connection ||= connect }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def connected? = !@connection.nil?
|
|
79
|
+
|
|
80
|
+
# The resolved connection settings (a Hash; see DB::Configuration).
|
|
81
|
+
def settings = @settings ||= Configuration.resolve
|
|
82
|
+
|
|
83
|
+
# "postgres", "mysql2", "trilogy" or "sqlite".
|
|
84
|
+
def adapter = settings[:adapter]
|
|
85
|
+
def postgres?(db = nil) = type(db) == :postgres
|
|
86
|
+
def mysql?(db = nil) = type(db) == :mysql
|
|
87
|
+
def sqlite?(db = nil) = type(db) == :sqlite
|
|
88
|
+
|
|
89
|
+
# Sequel's database type (:postgres, :mysql, :sqlite) — from the
|
|
90
|
+
# connection when there is one, so no query is needed.
|
|
91
|
+
def type(db = nil)
|
|
92
|
+
db ||= @connection
|
|
93
|
+
return db.database_type if db
|
|
94
|
+
|
|
95
|
+
{ "postgres" => :postgres, "mysql2" => :mysql, "trilogy" => :mysql, "sqlite" => :sqlite }.fetch(adapter)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Builds the Sequel::Database. Connections are opened lazily, so an
|
|
99
|
+
# unavailable database doesn't prevent the application from booting.
|
|
100
|
+
def connect(target = settings)
|
|
101
|
+
target = Configuration.from_url(target, root: GemStack.config.root) if target.is_a?(String)
|
|
102
|
+
opts = target.except(:source)
|
|
103
|
+
options = { max_connections: opts.delete(:max_connections) || config.pool_size,
|
|
104
|
+
pool_timeout: config.pool_timeout, test: false, keep_reference: false }
|
|
105
|
+
options.merge!(adapter_options(opts[:adapter], opts[:database]))
|
|
106
|
+
db = open_database(opts.merge(options).merge(config.options))
|
|
107
|
+
configure(db)
|
|
108
|
+
Sequel::Model.db = db
|
|
109
|
+
db
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Closes pooled connections (e.g. before Puma forks workers). The
|
|
113
|
+
# Database object stays in place and reconnects lazily: models hold a
|
|
114
|
+
# reference to it, so replacing it would split them from GemStack.db.
|
|
115
|
+
def disconnect
|
|
116
|
+
@connection&.disconnect
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def transaction(**, &) = connection.transaction(**, &)
|
|
120
|
+
|
|
121
|
+
# Forgets the resolved settings and the connection (tests, console).
|
|
122
|
+
def reset!
|
|
123
|
+
@connection&.disconnect
|
|
124
|
+
@connection = nil
|
|
125
|
+
@settings = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# True when the table isn't there — one catalog query, nothing logged
|
|
129
|
+
# (Sequel's table_exists? probes with a failing SELECT).
|
|
130
|
+
def table_missing?(db, table)
|
|
131
|
+
name = table.to_s
|
|
132
|
+
case db.database_type
|
|
133
|
+
when :postgres then db.get(Sequel.function(:to_regclass, db.literal(table))).nil?
|
|
134
|
+
when :mysql
|
|
135
|
+
db[Sequel[:information_schema][:tables]].where(table_schema: Sequel.function(:database),
|
|
136
|
+
table_name: name).empty?
|
|
137
|
+
when :sqlite then db[:sqlite_master].where(type: %w[table view], name: name).empty?
|
|
138
|
+
else !db.table_exists?(table)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def configure(db)
|
|
145
|
+
extensions = config.extensions || (postgres?(db) ? %i[pg_json pg_array] : [])
|
|
146
|
+
db.extension(*extensions) unless extensions.empty?
|
|
147
|
+
db.log_warn_duration = config.slow_query_ms / 1000.0 if config.slow_query_ms
|
|
148
|
+
db.loggers << QueryLogger.new(GemStack.logger, queries: config.log_queries)
|
|
149
|
+
db.sql_log_level = :debug
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def open_database(options)
|
|
153
|
+
Sequel.connect(**options)
|
|
154
|
+
rescue Sequel::AdapterNotFound, LoadError => e
|
|
155
|
+
gem_name = Configuration::DRIVER_GEMS.fetch(options[:adapter], options[:adapter])
|
|
156
|
+
raise ConfigurationError, "#{options[:adapter]} needs gem \"#{gem_name}\" in the Gemfile (#{e.message})"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def adapter_options(adapter, database)
|
|
160
|
+
timeout = config.statement_timeout && Integer(config.statement_timeout)
|
|
161
|
+
case adapter
|
|
162
|
+
when "postgres" then timeout ? { connect_sqls: ["SET statement_timeout = #{timeout}"] } : {}
|
|
163
|
+
when "mysql2", "trilogy"
|
|
164
|
+
sqls = timeout ? ["SET SESSION max_execution_time = #{timeout}"] : []
|
|
165
|
+
{ encoding: "utf8mb4", connect_sqls: sqls }
|
|
166
|
+
when "sqlite"
|
|
167
|
+
# Several Puma threads share the file: wait for the write lock instead
|
|
168
|
+
# of failing, and let readers work while one connection writes.
|
|
169
|
+
sqls = database == ":memory:" ? [] : ["PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL"]
|
|
170
|
+
{ timeout: 5_000, connect_sqls: sqls }
|
|
171
|
+
else {}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A model base class bound to an explicit table, like Sequel::Model(:table):
|
|
178
|
+
# class Item < GemStack::Model(:inventory_items)
|
|
179
|
+
def self.Model(source) = Model.Model(source) # rubocop:disable Naming/MethodName
|
|
180
|
+
|
|
181
|
+
class << self
|
|
182
|
+
def db = DB.connection
|
|
183
|
+
def transaction(**, &) = DB.transaction(**, &)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
require_relative "db/model"
|
|
188
|
+
require_relative "db/errors"
|
|
189
|
+
require_relative "db/migrator"
|
|
190
|
+
require_relative "db/tasks"
|
|
191
|
+
|
|
192
|
+
GemStack::Config.namespace(:db, GemStack::DB::Config)
|
|
193
|
+
|
|
194
|
+
GemStack::Plugins.register(:db) do |app|
|
|
195
|
+
GemStack::DB.connection
|
|
196
|
+
app.on_shutdown { GemStack::DB.disconnect } if app.respond_to?(:on_shutdown)
|
|
197
|
+
|
|
198
|
+
# A friendly nudge in development; never blocks boot.
|
|
199
|
+
if GemStack.env.development?
|
|
200
|
+
begin
|
|
201
|
+
pending = GemStack::DB::Migrator.new.pending
|
|
202
|
+
unless pending.empty?
|
|
203
|
+
GemStack.logger.warn("#{pending.size} pending migration(s) — run `gemstack db:migrate`",
|
|
204
|
+
first: pending.first.file)
|
|
205
|
+
end
|
|
206
|
+
rescue Sequel::Error
|
|
207
|
+
nil # database not reachable yet; requests will report it
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Dev
|
|
7
|
+
# Detects changes (edits, additions, deletions) in a set of glob patterns.
|
|
8
|
+
# Polling keeps it dependency-free and reliable across editors, containers
|
|
9
|
+
# and network filesystems; it is only used in development.
|
|
10
|
+
#
|
|
11
|
+
# Modification times are the cheap first check; a file whose mtime changed
|
|
12
|
+
# only counts as changed if its content digest changed too. Tools that
|
|
13
|
+
# rewrite files with identical content — Bundler 4 rewrites Gemfile.lock on
|
|
14
|
+
# every `bundle exec` — therefore don't trigger reloads or restarts.
|
|
15
|
+
class FileWatcher
|
|
16
|
+
Entry = Struct.new(:mtime, :bytes, :digest)
|
|
17
|
+
|
|
18
|
+
def initialize(patterns, root: Dir.pwd, exclude: [])
|
|
19
|
+
@patterns = Array(patterns)
|
|
20
|
+
@exclude = Array(exclude)
|
|
21
|
+
@root = root.to_s
|
|
22
|
+
@entries = scan({})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True once per change: the new state becomes the baseline.
|
|
26
|
+
def changed?
|
|
27
|
+
current = scan(@entries)
|
|
28
|
+
changed = current.keys != @entries.keys ||
|
|
29
|
+
current.any? { |path, entry| entry.digest != @entries[path].digest }
|
|
30
|
+
@entries = current
|
|
31
|
+
changed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def files
|
|
35
|
+
found = Dir.glob(@patterns, base: @root)
|
|
36
|
+
found.reject! { |file| @exclude.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) } }
|
|
37
|
+
found.sort
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Reuses the previous digest when mtime and size are unchanged.
|
|
43
|
+
def scan(previous)
|
|
44
|
+
files.each_with_object({}) do |file, entries|
|
|
45
|
+
path = File.join(@root, file)
|
|
46
|
+
stat = File.stat(path)
|
|
47
|
+
next if stat.directory?
|
|
48
|
+
|
|
49
|
+
old = previous[file]
|
|
50
|
+
same = old && old.mtime == stat.mtime && old.bytes == stat.size
|
|
51
|
+
entries[file] = Entry.new(stat.mtime, stat.size, same ? old.digest : Digest::SHA256.file(path).hexdigest)
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
module Dev
|
|
8
|
+
# The development gateway: one public origin in front of Next.js and the
|
|
9
|
+
# Ruby API.
|
|
10
|
+
#
|
|
11
|
+
# For each connection it reads only the request line and headers, picks
|
|
12
|
+
# an upstream by path (api_path → API, everything else → frontend), adds
|
|
13
|
+
# X-Forwarded-* headers and then copies raw bytes in both directions.
|
|
14
|
+
# Because it never interprets bodies, streaming responses, Server-Sent
|
|
15
|
+
# Events and WebSocket upgrades (Next.js HMR, realtime) just work.
|
|
16
|
+
#
|
|
17
|
+
# Each request gets its own upstream connection (`Connection: close`
|
|
18
|
+
# upstream), so routing stays correct per request without parsing
|
|
19
|
+
# response framing. That trade-off is fine on localhost and is the reason
|
|
20
|
+
# this gateway is for development only.
|
|
21
|
+
class Gateway
|
|
22
|
+
Upstream = Struct.new(:name, :host, :port, :label) do
|
|
23
|
+
def to_s = "#{host}:#{port}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
MAX_HEAD = 64 * 1024
|
|
27
|
+
CONNECT_TIMEOUT = 2
|
|
28
|
+
HOP_BY_HOP = %w[connection keep-alive proxy-connection].freeze
|
|
29
|
+
FORWARDED = %w[x-forwarded-for x-forwarded-proto x-forwarded-host x-forwarded-port].freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :port
|
|
32
|
+
attr_accessor :api, :frontend
|
|
33
|
+
|
|
34
|
+
def initialize(port:, api_path:, api:, frontend: nil, bind: %w[127.0.0.1 ::1], on_error: nil)
|
|
35
|
+
@port = port
|
|
36
|
+
@api_path = api_path.to_s.chomp("/")
|
|
37
|
+
@api = api
|
|
38
|
+
@frontend = frontend
|
|
39
|
+
@bind = Array(bind)
|
|
40
|
+
@on_error = on_error
|
|
41
|
+
@servers = []
|
|
42
|
+
@threads = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def start
|
|
46
|
+
@bind.each do |host|
|
|
47
|
+
server = listen(host)
|
|
48
|
+
next unless server
|
|
49
|
+
|
|
50
|
+
@port = server.addr[1] if @port.zero? # port 0: let the OS choose (tests)
|
|
51
|
+
@servers << server
|
|
52
|
+
end
|
|
53
|
+
raise Error, "could not listen on any of #{@bind.join(", ")}" if @servers.empty?
|
|
54
|
+
|
|
55
|
+
@threads = @servers.map { |server| Thread.new { accept_loop(server) } }
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stop
|
|
60
|
+
@servers.each { |server| server.close unless server.closed? }
|
|
61
|
+
@threads.each { |thread| thread.join(1) }
|
|
62
|
+
@servers.clear
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def api_request?(path)
|
|
66
|
+
return true if @api_path.empty?
|
|
67
|
+
|
|
68
|
+
path == @api_path || path.start_with?("#{@api_path}/")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def listen(host)
|
|
74
|
+
TCPServer.new(host, @port)
|
|
75
|
+
rescue Errno::EADDRINUSE
|
|
76
|
+
stop
|
|
77
|
+
raise Error,
|
|
78
|
+
"port #{@port} is already in use on #{host}. Stop the other process or run PORT=#{@port + 1} gemstack dev"
|
|
79
|
+
rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError
|
|
80
|
+
nil # e.g. no IPv6 loopback on this machine
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def accept_loop(server)
|
|
84
|
+
loop do
|
|
85
|
+
client = server.accept
|
|
86
|
+
Thread.new(client) { |socket| handle(socket) }
|
|
87
|
+
end
|
|
88
|
+
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
89
|
+
nil # server closed
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle(client)
|
|
93
|
+
head, rest = read_head(client)
|
|
94
|
+
return unless head
|
|
95
|
+
|
|
96
|
+
request_line, *header_lines = head.split("\r\n")
|
|
97
|
+
_method, target, = request_line.split(" ", 3)
|
|
98
|
+
return respond(client, 400, "text/plain", "Bad Request") unless target
|
|
99
|
+
|
|
100
|
+
path = target.sub(%r{\Ahttps?://[^/]+}i, "").split("?", 2).first
|
|
101
|
+
api = api_request?(path)
|
|
102
|
+
upstream = api ? @api : @frontend
|
|
103
|
+
return unavailable(client, api, header_lines, nil) unless upstream
|
|
104
|
+
|
|
105
|
+
server = connect(upstream)
|
|
106
|
+
return unavailable(client, api, header_lines, upstream) unless server
|
|
107
|
+
|
|
108
|
+
server.write("#{request_line}\r\n#{rewrite(header_lines, client).join("\r\n")}\r\n\r\n")
|
|
109
|
+
server.write(rest) unless rest.empty?
|
|
110
|
+
relay(client, server)
|
|
111
|
+
rescue IOError, SystemCallError
|
|
112
|
+
nil # client or upstream went away
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
@on_error&.call(e)
|
|
115
|
+
ensure
|
|
116
|
+
[client, server].each { |socket| socket&.close unless socket&.closed? }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_head(client)
|
|
120
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
121
|
+
until (index = buffer.index("\r\n\r\n"))
|
|
122
|
+
return nil if buffer.bytesize > MAX_HEAD
|
|
123
|
+
|
|
124
|
+
buffer << client.readpartial(16 * 1024)
|
|
125
|
+
end
|
|
126
|
+
[buffer.byteslice(0, index), buffer.byteslice(index + 4, buffer.bytesize)]
|
|
127
|
+
rescue IOError, SystemCallError # EOFError is an IOError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rewrite(lines, client)
|
|
132
|
+
upgrade = upgrade?(lines)
|
|
133
|
+
host = nil
|
|
134
|
+
kept = lines.reject do |line|
|
|
135
|
+
name = line[/\A[^:]+/].to_s.strip.downcase
|
|
136
|
+
host = line.split(":", 2).last.strip if name == "host"
|
|
137
|
+
FORWARDED.include?(name) || (!upgrade && HOP_BY_HOP.include?(name))
|
|
138
|
+
end
|
|
139
|
+
kept << "Connection: close" unless upgrade
|
|
140
|
+
kept << "X-Forwarded-For: #{client_ip(client)}"
|
|
141
|
+
kept << "X-Forwarded-Proto: http"
|
|
142
|
+
kept << "X-Forwarded-Host: #{host}" if host
|
|
143
|
+
kept << "X-Forwarded-Port: #{@port}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def upgrade?(lines)
|
|
147
|
+
connection = lines.find { |l| l.match?(/\Aconnection\s*:/i) }
|
|
148
|
+
connection&.match?(/upgrade/i) && lines.any? { |l| l.match?(/\Aupgrade\s*:/i) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def client_ip(client)
|
|
152
|
+
client.remote_address.ip_address
|
|
153
|
+
rescue StandardError
|
|
154
|
+
"127.0.0.1"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def connect(upstream)
|
|
158
|
+
Socket.tcp(upstream.host, upstream.port, connect_timeout: CONNECT_TIMEOUT)
|
|
159
|
+
rescue SystemCallError, SocketError, IOError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Upstream → client on this thread; client → upstream on a helper
|
|
164
|
+
# thread. The exchange ends when the upstream closes (after its
|
|
165
|
+
# response, or when a WebSocket/stream ends).
|
|
166
|
+
def relay(client, server)
|
|
167
|
+
pump = Thread.new do
|
|
168
|
+
IO.copy_stream(client, server)
|
|
169
|
+
server.close_write
|
|
170
|
+
rescue IOError, SystemCallError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
IO.copy_stream(server, client)
|
|
174
|
+
rescue IOError, SystemCallError
|
|
175
|
+
nil
|
|
176
|
+
ensure
|
|
177
|
+
pump&.kill
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def unavailable(client, api, header_lines, upstream)
|
|
181
|
+
name = api ? "Ruby API" : "Next.js"
|
|
182
|
+
state = upstream ? "is starting or not running" : "is not configured"
|
|
183
|
+
message = "#{name} #{state}. Check the terminal running `gemstack dev`."
|
|
184
|
+
if api
|
|
185
|
+
body = JSON.generate(error: { code: "upstream_unavailable", message: message })
|
|
186
|
+
respond(client, 503, "application/json", body)
|
|
187
|
+
elsif header_lines.any? { |l| l.match?(%r{\Aaccept\s*:.*text/html}i) }
|
|
188
|
+
respond(client, 503, "text/html; charset=utf-8", waiting_page(name, message, refresh: !upstream.nil?))
|
|
189
|
+
else
|
|
190
|
+
respond(client, 503, "text/plain", message)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def respond(client, status, type, body)
|
|
195
|
+
reason = { 400 => "Bad Request", 503 => "Service Unavailable" }.fetch(status, "Error")
|
|
196
|
+
client.write("HTTP/1.1 #{status} #{reason}\r\ncontent-type: #{type}\r\ncontent-length: #{body.bytesize}\r\n" \
|
|
197
|
+
"cache-control: no-store\r\nretry-after: 1\r\nconnection: close\r\n\r\n#{body}")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def waiting_page(name, message, refresh:)
|
|
201
|
+
<<~HTML
|
|
202
|
+
<!doctype html>
|
|
203
|
+
<html lang="en"><head><meta charset="utf-8"><title>GemStack · #{name}</title>
|
|
204
|
+
#{'<meta http-equiv="refresh" content="1">' if refresh}
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
206
|
+
<style>
|
|
207
|
+
body{font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;margin:0;
|
|
208
|
+
background:#0b0d12;color:#e6e8ee}
|
|
209
|
+
main{max-width:32rem;padding:2rem;text-align:center}
|
|
210
|
+
.dot{display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#f5b041;
|
|
211
|
+
margin-right:.5rem;animation:p 1s infinite alternate}
|
|
212
|
+
@keyframes p{to{opacity:.3}} code{background:#1c2030;padding:.1rem .35rem;border-radius:4px}
|
|
213
|
+
</style></head>
|
|
214
|
+
<body><main><h1>GemStack</h1><p><span class="dot"></span>#{message.sub(/`([^`]+)`/, '<code>\1</code>')}</p>
|
|
215
|
+
<p style="color:#8a90a2">#{"This page refreshes automatically." if refresh}</p></main></body></html>
|
|
216
|
+
HTML
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Dev
|
|
5
|
+
# A child process whose output is streamed, line by line, to a Terminal.
|
|
6
|
+
# Each child runs in its own process group so stopping it also stops
|
|
7
|
+
# anything it spawned (e.g. npm → node).
|
|
8
|
+
class ManagedProcess
|
|
9
|
+
attr_reader :name, :command, :pid, :status
|
|
10
|
+
|
|
11
|
+
def initialize(name, command, terminal:, env: {}, chdir: Dir.pwd)
|
|
12
|
+
@name = name
|
|
13
|
+
@command = Array(command)
|
|
14
|
+
@terminal = terminal
|
|
15
|
+
@env = env
|
|
16
|
+
@chdir = chdir.to_s
|
|
17
|
+
@pid = nil
|
|
18
|
+
@status = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def start
|
|
22
|
+
reader, writer = IO.pipe
|
|
23
|
+
@status = nil
|
|
24
|
+
@pid = Process.spawn(@env, *@command, chdir: @chdir, out: writer, err: writer, in: File::NULL,
|
|
25
|
+
pgroup: true)
|
|
26
|
+
writer.close
|
|
27
|
+
@output = Thread.new do
|
|
28
|
+
reader.each_line { |line| @terminal.line(@name, line) }
|
|
29
|
+
rescue IOError
|
|
30
|
+
nil
|
|
31
|
+
ensure
|
|
32
|
+
reader.close
|
|
33
|
+
end
|
|
34
|
+
self
|
|
35
|
+
rescue SystemCallError => e
|
|
36
|
+
writer&.close
|
|
37
|
+
reader&.close
|
|
38
|
+
@terminal.line(@name, "could not start `#{@command.join(" ")}`: #{e.message}")
|
|
39
|
+
@status = :failed_to_start
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Non-blocking. Records the exit status once the process has exited.
|
|
44
|
+
def running?
|
|
45
|
+
return false unless @pid
|
|
46
|
+
return false if @status
|
|
47
|
+
|
|
48
|
+
done = Process.waitpid2(@pid, Process::WNOHANG)
|
|
49
|
+
return true unless done
|
|
50
|
+
|
|
51
|
+
@status = done[1]
|
|
52
|
+
false
|
|
53
|
+
rescue Errno::ECHILD
|
|
54
|
+
@status ||= :unknown
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def exited? = !@status.nil?
|
|
59
|
+
|
|
60
|
+
def stop(timeout: 5)
|
|
61
|
+
return unless running?
|
|
62
|
+
|
|
63
|
+
signal("TERM")
|
|
64
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
65
|
+
sleep 0.05 while running? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
66
|
+
signal("KILL") if running?
|
|
67
|
+
running?
|
|
68
|
+
@output&.join(1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def restart
|
|
72
|
+
stop
|
|
73
|
+
start
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def describe_status
|
|
77
|
+
case @status
|
|
78
|
+
when Process::Status then @status.signaled? ? "signal #{@status.termsig}" : "status #{@status.exitstatus}"
|
|
79
|
+
else @status.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def signal(name)
|
|
86
|
+
Process.kill(name, -@pid)
|
|
87
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
88
|
+
begin
|
|
89
|
+
Process.kill(name, @pid)
|
|
90
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Dev
|
|
7
|
+
module Ports
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# An unused TCP port on host, chosen by the OS.
|
|
11
|
+
def free(host = "127.0.0.1")
|
|
12
|
+
server = TCPServer.new(host, 0)
|
|
13
|
+
server.addr[1]
|
|
14
|
+
ensure
|
|
15
|
+
server&.close
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def open?(host, port, timeout: 0.5)
|
|
19
|
+
Socket.tcp(host, port, connect_timeout: timeout).close
|
|
20
|
+
true
|
|
21
|
+
rescue SystemCallError, SocketError, IOError
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|