gemstack 0.3.6 → 0.4.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 +3 -3
- data/lib/gemstack/application.rb +4 -0
- data/lib/gemstack/cli/app_generator.rb +9 -4
- data/lib/gemstack/cli/doctor.rb +2 -1
- data/lib/gemstack/cli/migration_changes.rb +3 -2
- data/lib/gemstack/cli/resource_generator.rb +14 -1
- data/lib/gemstack/cli/resource_spec.rb +25 -8
- data/lib/gemstack/cli/template_update.rb +3 -2
- data/lib/gemstack/cli.rb +1 -1
- data/lib/gemstack/contract/builder.rb +14 -2
- data/lib/gemstack/contract/openapi.rb +1 -0
- data/lib/gemstack/contract/typescript.rb +1 -0
- data/lib/gemstack/db/model.rb +85 -5
- data/lib/gemstack/db/testing.rb +2 -1
- data/lib/gemstack/errors.rb +11 -1
- data/lib/gemstack/interlock.rb +8 -0
- data/lib/gemstack/reloader.rb +1 -1
- data/lib/gemstack/schema.rb +3 -1
- data/lib/gemstack/version.rb +1 -1
- data/templates/app/Gemfile.tt +1 -1
- data/templates/app/config/app.rb.tt +2 -1
- data/templates/app/config/database.yml.tt +4 -1
- data/templates/deploy/config/deploy.yml.tt +2 -0
- data/templates/realtime/config/channels.rb +20 -5
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +502 -59
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +4 -2
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b4da4865f171f049ffb5861de51b0f9e376e8e684dac8c897c760194b1269ad
|
|
4
|
+
data.tar.gz: 678190d65346a0542c88f0e977227c477666d98c92bf252eb5d03cae23116796
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d16bf203a45f8b40a37dd13cac7d31c983b5f71ea84c6ae90d5dc566f306b650b6ab3410edcfe1c21248f0accf3af9870db4f57567727a0395678beb65d6897
|
|
7
|
+
data.tar.gz: 392c2b983fb86297436e835136c92fc37c19fe6649bb55b3ea5c7f9d0e058849e397634ebcd612806d264f27edaced07ed55689c4b1504e28d0abcfe5ed8c531
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# gemstack
|
|
2
2
|
|
|
3
|
-
GemStack: a fast, modular Ruby
|
|
3
|
+
GemStack: a fast, modular Ruby web application framework with a Next.js frontend.
|
|
4
4
|
|
|
5
|
-
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby
|
|
6
|
-
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby web application framework with
|
|
6
|
+
a Next.js frontend, by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed
|
|
7
7
|
together in that repository and released with the same version.
|
|
8
8
|
|
|
9
9
|
## Installation
|
data/lib/gemstack/application.rb
CHANGED
|
@@ -99,6 +99,10 @@ module GemStack
|
|
|
99
99
|
(app_dirs + extra).select { |path| File.directory?(path) }
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Held shared while application code runs (requests, realtime handlers),
|
|
103
|
+
# exclusively while code reloads.
|
|
104
|
+
def interlock = @interlock ||= Interlock.new
|
|
105
|
+
|
|
102
106
|
private
|
|
103
107
|
|
|
104
108
|
def load_env_files = GemStack.load_env_files!
|
|
@@ -66,6 +66,9 @@ module GemStack
|
|
|
66
66
|
(!frontend? && %w[dot_node-version.tt dot_nvmrc.tt].include?(rel))
|
|
67
67
|
})
|
|
68
68
|
render_directory("frontend", File.join(destination, "frontend")) if frontend?
|
|
69
|
+
# Background jobs work from the start: the jobs table is part of every app
|
|
70
|
+
# with a database, so `gemstack dev` runs a worker right away.
|
|
71
|
+
JobGenerator.install_migration(destination, output: @output, timestamp: @options[:jobs_timestamp]) if database?
|
|
69
72
|
install unless @options[:skip_install]
|
|
70
73
|
git_init unless @options[:skip_git]
|
|
71
74
|
summary
|
|
@@ -100,13 +103,15 @@ module GemStack
|
|
|
100
103
|
end
|
|
101
104
|
|
|
102
105
|
# Best effort: a missing or password-protected database server shouldn't fail `new`.
|
|
106
|
+
# Migrating creates the jobs table, so the dev worker can start at once.
|
|
103
107
|
def create_database
|
|
104
|
-
@output.puts(" #{"run".rjust(9)} gemstack db:create")
|
|
105
|
-
|
|
108
|
+
@output.puts(" #{"run".rjust(9)} gemstack db:create db:migrate")
|
|
109
|
+
quiet = { chdir: destination, out: File::NULL, err: File::NULL }
|
|
110
|
+
ok = unbundled { system("bin/gemstack", "db:create", **quiet) && system("bin/gemstack", "db:migrate", **quiet) }
|
|
106
111
|
return if ok
|
|
107
112
|
|
|
108
|
-
@output.puts(" #{"warning".rjust(9)} couldn't create the database — " \
|
|
109
|
-
"
|
|
113
|
+
@output.puts(" #{"warning".rjust(9)} couldn't create or migrate the database — check config/database.yml " \
|
|
114
|
+
"(or set DATABASE_URL in .env), then run `gemstack db:create db:migrate`")
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
def git_init
|
data/lib/gemstack/cli/doctor.rb
CHANGED
|
@@ -219,7 +219,8 @@ module GemStack
|
|
|
219
219
|
"set DATABASE_URL, or a production section in config/database.yml")
|
|
220
220
|
elsif settings[:adapter] == "sqlite"
|
|
221
221
|
caution("SQLite in production (#{settings[:database]})",
|
|
222
|
-
"
|
|
222
|
+
"PostgreSQL is recommended: add gem \"pg\" and set DATABASE_URL=postgres://… — SQLite only " \
|
|
223
|
+
"suits one server, with the file on a persistent volume and backups")
|
|
223
224
|
else
|
|
224
225
|
pass("production database: #{GemStack::DB::Configuration.describe(settings)} (#{settings[:source]})")
|
|
225
226
|
end
|
|
@@ -133,8 +133,9 @@ module GemStack
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def field_argument(field)
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
modifiers = { "optional" => field.optional, "unique" => field.unique,
|
|
137
|
+
"index" => field.index && !field.reference? }.select { |_, on| on }.keys
|
|
138
|
+
[field.name, field.type, field.enum_values&.join(","), *modifiers].compact.join(":")
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
def manual_review
|
|
@@ -63,6 +63,9 @@ module GemStack
|
|
|
63
63
|
when "references"
|
|
64
64
|
"foreign_key :#{field.column}, :#{field.referenced_table}, type: :Bignum#{null}, on_delete: :restrict"
|
|
65
65
|
when "boolean" then "TrueClass :#{field.name}, null: false, default: false"
|
|
66
|
+
when "enum"
|
|
67
|
+
default = field.default_value ? ", null: false, default: #{field.default_value.inspect}" : ""
|
|
68
|
+
"String :#{field.name}, size: 50#{default}#{", unique: true" if field.unique}"
|
|
66
69
|
else
|
|
67
70
|
unique = field.unique && field.type != "json" ? ", unique: true" : ""
|
|
68
71
|
format(COLUMN_TYPES.fetch(field.type), field.name) + null + unique
|
|
@@ -70,6 +73,11 @@ module GemStack
|
|
|
70
73
|
end
|
|
71
74
|
|
|
72
75
|
def model_field(field)
|
|
76
|
+
if field.enum?
|
|
77
|
+
default = field.default_value ? ", default: #{field.default_value.inspect}" : ", null: true"
|
|
78
|
+
return "enum :#{field.name}, %w[#{field.enum_values.join(" ")}]#{default}"
|
|
79
|
+
end
|
|
80
|
+
|
|
73
81
|
options = []
|
|
74
82
|
options << "null: false" if field.required?
|
|
75
83
|
options << "null: false, default: false" if field.type == "boolean"
|
|
@@ -87,6 +95,7 @@ module GemStack
|
|
|
87
95
|
when "integer", "bigint", "float", "references" then "String(#{attr} ?? \"\")"
|
|
88
96
|
when "datetime" then "(#{attr} ?? \"\").slice(0, 16)"
|
|
89
97
|
when "json" then "#{attr} == null ? \"\" : JSON.stringify(#{attr}, null, 2)"
|
|
98
|
+
when "enum" then "#{attr} ?? #{field.default_value.to_s.inspect}"
|
|
90
99
|
else "#{attr} ?? \"\""
|
|
91
100
|
end
|
|
92
101
|
end
|
|
@@ -94,7 +103,7 @@ module GemStack
|
|
|
94
103
|
# Expression turning a form value into an API input value.
|
|
95
104
|
def form_output(field)
|
|
96
105
|
value = "values.#{field.column}"
|
|
97
|
-
return value if field.type == "boolean"
|
|
106
|
+
return value if field.type == "boolean" || (field.enum? && !field.optional)
|
|
98
107
|
|
|
99
108
|
converted =
|
|
100
109
|
case field.type
|
|
@@ -115,6 +124,10 @@ module GemStack
|
|
|
115
124
|
%(<input #{common} type="checkbox" checked={values.#{key}} onChange={set("#{key}")} />)
|
|
116
125
|
when "text", "json"
|
|
117
126
|
%(<textarea #{common} rows={4} value={values.#{key}} onChange={set("#{key}")}#{required} />)
|
|
127
|
+
when "enum"
|
|
128
|
+
options = field.enum_values.map { |value| %(<option value="#{value}">#{Inflector.humanize(value)}</option>) }
|
|
129
|
+
options.unshift(%(<option value="">—</option>)) if field.optional
|
|
130
|
+
%(<select #{common} value={values.#{key}} onChange={set("#{key}")}>#{options.join}</select>)
|
|
118
131
|
else
|
|
119
132
|
attrs = { "integer" => %(type="number" step="1"), "bigint" => %(type="number" step="1"),
|
|
120
133
|
"references" => %(type="number" step="1"), "float" => %(type="number" step="any"),
|
|
@@ -6,18 +6,23 @@ module GemStack
|
|
|
6
6
|
# resource, parsed once from the command line (ARCHITECTURE §7):
|
|
7
7
|
#
|
|
8
8
|
# gemstack generate resource Product name:string price:decimal description:text:optional \
|
|
9
|
-
# sku:string:unique category:references active:boolean
|
|
9
|
+
# sku:string:unique category:references active:boolean \
|
|
10
|
+
# status:enum:draft,published,archived
|
|
10
11
|
#
|
|
11
|
-
# Field syntax: name:type[:modifier...]
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# Field syntax: name:type[:modifier...]; enums list their values first
|
|
13
|
+
# (name:enum:a,b,c[:modifier...]). Fields are required (NOT NULL) unless
|
|
14
|
+
# marked :optional. Booleans default to false, enums to their first value.
|
|
15
|
+
# Modifiers: optional, unique, index.
|
|
14
16
|
class ResourceSpec
|
|
15
|
-
TYPES = %w[string text integer bigint float decimal boolean date datetime uuid json references].freeze
|
|
17
|
+
TYPES = %w[string text integer bigint float decimal boolean date datetime uuid json references enum].freeze
|
|
16
18
|
MODIFIERS = %w[optional unique index].freeze
|
|
17
19
|
REST_ACTIONS = %w[index show create update destroy].freeze
|
|
18
20
|
|
|
19
|
-
Field = Struct.new(:name, :type, :optional, :unique, :index, keyword_init: true) do
|
|
20
|
-
|
|
21
|
+
Field = Struct.new(:name, :type, :optional, :unique, :index, :enum_values, keyword_init: true) do
|
|
22
|
+
# Booleans and enums are NOT NULL too, but with a default: nothing to require.
|
|
23
|
+
def required? = !optional && !%w[boolean enum].include?(type)
|
|
24
|
+
def enum? = type == "enum"
|
|
25
|
+
def default_value = enum? && !optional ? enum_values.first : nil
|
|
21
26
|
def reference? = type == "references"
|
|
22
27
|
def column = reference? ? "#{name}_id" : name
|
|
23
28
|
def label = Inflector.humanize(name)
|
|
@@ -78,12 +83,24 @@ module GemStack
|
|
|
78
83
|
"Unknown type #{type.inspect} in #{arg.inspect}; types: #{TYPES.join(", ")}"
|
|
79
84
|
end
|
|
80
85
|
|
|
86
|
+
enum_values = parse_enum_values(arg, mods) if type == "enum"
|
|
81
87
|
unknown = mods - MODIFIERS
|
|
82
88
|
raise Thor::Error, "Unknown modifier(s) #{unknown.join(", ")} in #{arg.inspect}" unless unknown.empty?
|
|
83
89
|
|
|
84
90
|
name = name.delete_suffix("_id") if type == "references"
|
|
85
91
|
Field.new(name: name, type: type, optional: mods.include?("optional"), unique: mods.include?("unique"),
|
|
86
|
-
index: mods.include?("index") || type == "references")
|
|
92
|
+
index: mods.include?("index") || type == "references", enum_values: enum_values)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# status:enum:draft,published → ["draft", "published"] (taken off mods).
|
|
96
|
+
def parse_enum_values(arg, mods)
|
|
97
|
+
values = mods.shift.to_s.split(",").map(&:strip)
|
|
98
|
+
if values.empty? || values.any? { |value| !value.match?(/\A[a-z][a-z0-9_]*\z/) } || values.uniq != values
|
|
99
|
+
raise Thor::Error, "Give an enum's values in #{arg.inspect}, e.g. status:enum:draft,published " \
|
|
100
|
+
"(lowercase letters, digits and _, no duplicates)"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
values
|
|
87
104
|
end
|
|
88
105
|
|
|
89
106
|
def lower_camel(term)
|
|
@@ -84,7 +84,8 @@ module GemStack
|
|
|
84
84
|
# templates (offline), files that differ are offered, never updated.
|
|
85
85
|
class TemplateUpdate
|
|
86
86
|
VERSION_FILE = ".gemstack/version"
|
|
87
|
-
# Managed elsewhere: the Gemfile by `gemstack update`/Bundler.
|
|
87
|
+
# Managed elsewhere: the Gemfile by `gemstack update`/Bundler. Migrations
|
|
88
|
+
# (db/migrations/) are the app's history and never part of an update.
|
|
88
89
|
MANAGED = %w[Gemfile .gemstack/version].freeze
|
|
89
90
|
|
|
90
91
|
# created/updated/saved (as FILE.new) are done; conflicts were left for you;
|
|
@@ -171,7 +172,7 @@ module GemStack
|
|
|
171
172
|
AppGenerator.new(destination, options, output: StringIO.new).run
|
|
172
173
|
Dir.glob("**/*", File::FNM_DOTMATCH, base: destination).sort.filter_map do |rel|
|
|
173
174
|
full = File.join(destination, rel)
|
|
174
|
-
next unless File.file?(full) && !MANAGED.include?(rel)
|
|
175
|
+
next unless File.file?(full) && !MANAGED.include?(rel) && !rel.start_with?("db/migrations/")
|
|
175
176
|
|
|
176
177
|
[rel, AppFile.new(File.binread(full), File.stat(full).mode)]
|
|
177
178
|
end.to_h
|
data/lib/gemstack/cli.rb
CHANGED
|
@@ -241,7 +241,7 @@ module GemStack
|
|
|
241
241
|
are updated, and for ones you edited you choose: overwrite, skip, see the diff, or FILE.new.
|
|
242
242
|
|
|
243
243
|
gemstack update # the latest release
|
|
244
|
-
gemstack update 0.
|
|
244
|
+
gemstack update 0.4.0 # a specific version
|
|
245
245
|
gemstack update --templates --dry-run # only the template step, preview
|
|
246
246
|
gemstack update --templates --from 0.3.5
|
|
247
247
|
DESC
|
|
@@ -138,13 +138,22 @@ module GemStack
|
|
|
138
138
|
if attr[:type] == :json && !serializer.attributes_list[attr[:name]].type
|
|
139
139
|
@warnings << "#{serializer.name}##{attr[:name]}: type unknown, emitted as unknown"
|
|
140
140
|
end
|
|
141
|
-
|
|
141
|
+
type = enum_values(serializer, attr)&.then { |values| { enum: values } } || type_ref(attr[:type])
|
|
142
|
+
{ name: attr[:name].to_s, type: type, nullable: attr[:nullable], optional: false }
|
|
142
143
|
end
|
|
143
144
|
@types[name] = { fields: fields }
|
|
144
145
|
end
|
|
145
146
|
{ ref: name }
|
|
146
147
|
end
|
|
147
148
|
|
|
149
|
+
# An inferred string attribute backed by a model enum is a union type.
|
|
150
|
+
def enum_values(serializer, attr)
|
|
151
|
+
return nil unless attr[:type] == :string && serializer.attributes_list[attr[:name]]&.type.nil?
|
|
152
|
+
|
|
153
|
+
field = serializer.model&.gemstack_fields&.[](attr[:name])
|
|
154
|
+
field && field.options[:enum]
|
|
155
|
+
end
|
|
156
|
+
|
|
148
157
|
def schema_ref(schema, fallback_name)
|
|
149
158
|
name = schema.type_name || fallback_name
|
|
150
159
|
@types[name] ||= { fields: schema_fields(schema) }
|
|
@@ -153,7 +162,10 @@ module GemStack
|
|
|
153
162
|
|
|
154
163
|
def schema_fields(schema)
|
|
155
164
|
schema.fields.values.map do |field|
|
|
156
|
-
type = field.schema
|
|
165
|
+
type = if field.schema then { object: schema_fields(field.schema) }
|
|
166
|
+
elsif field.rules[:enum] then { enum: field.rules[:enum].map(&:to_s) }
|
|
167
|
+
else { scalar: field.type }
|
|
168
|
+
end
|
|
157
169
|
type = { array: type } if field.array
|
|
158
170
|
{ name: field.name.to_s, type: type, nullable: field.nullable, optional: field.ts_optional? }
|
|
159
171
|
end
|
|
@@ -99,6 +99,7 @@ module GemStack
|
|
|
99
99
|
def schema_for(ref)
|
|
100
100
|
if ref[:scalar] then Types.fetch(ref[:scalar]).openapi.dup
|
|
101
101
|
elsif ref[:ref] then { "$ref": "#/components/schemas/#{ref[:ref]}" }
|
|
102
|
+
elsif ref[:enum] then { type: "string", enum: ref[:enum] }
|
|
102
103
|
elsif ref[:array] then { type: "array", items: schema_for(ref[:array]) }
|
|
103
104
|
elsif ref[:page] then page_schema(ref[:page])
|
|
104
105
|
elsif ref[:object] then object_schema(ref[:object])
|
|
@@ -73,6 +73,7 @@ module GemStack
|
|
|
73
73
|
def ts_type(ref, depth = 0)
|
|
74
74
|
if ref[:scalar] then Types.fetch(ref[:scalar]).ts
|
|
75
75
|
elsif ref[:ref] then ref[:ref]
|
|
76
|
+
elsif ref[:enum] then ref[:enum].map { |value| JSON.generate(value) }.join(" | ")
|
|
76
77
|
elsif ref[:array]
|
|
77
78
|
inner = ts_type(ref[:array], depth)
|
|
78
79
|
inner.match?(/\A[\w.]+\z/) ? "#{inner}[]" : "Array<#{inner}>"
|
data/lib/gemstack/db/model.rb
CHANGED
|
@@ -8,6 +8,8 @@ module GemStack
|
|
|
8
8
|
# field :price, :decimal, null: false, gt: 0
|
|
9
9
|
# field :active, :boolean, null: false, default: true
|
|
10
10
|
#
|
|
11
|
+
# enum :status, %w[draft published archived], default: "draft"
|
|
12
|
+
#
|
|
11
13
|
# validates :name, format: /\A\S/
|
|
12
14
|
# belongs_to :category
|
|
13
15
|
# has_many :reviews
|
|
@@ -26,7 +28,7 @@ module GemStack
|
|
|
26
28
|
class Model
|
|
27
29
|
Field = Struct.new(:name, :type, :options)
|
|
28
30
|
|
|
29
|
-
FIELD_RULES = %i[null default size gt gte lt lte in format].freeze
|
|
31
|
+
FIELD_RULES = %i[null default size gt gte lt lte in enum format].freeze
|
|
30
32
|
|
|
31
33
|
plugin :timestamps, update_on_create: true
|
|
32
34
|
plugin :validation_helpers
|
|
@@ -46,7 +48,8 @@ module GemStack
|
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Declares a field. Options: null: false (required), default:, size:
|
|
49
|
-
# (max length), gt/gte/lt/lte, in:,
|
|
51
|
+
# (max length), gt/gte/lt/lte, in:, enum: (like in:, and a union type
|
|
52
|
+
# in TypeScript), format:. Types are GemStack::Types.
|
|
50
53
|
def field(name, type, **options)
|
|
51
54
|
Types.fetch(type)
|
|
52
55
|
unknown = options.keys - FIELD_RULES
|
|
@@ -58,6 +61,41 @@ module GemStack
|
|
|
58
61
|
options.freeze)
|
|
59
62
|
end
|
|
60
63
|
|
|
64
|
+
def gemstack_enums
|
|
65
|
+
@gemstack_enums ||= superclass.respond_to?(:gemstack_enums) ? superclass.gemstack_enums.dup : {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A string column limited to a list of values:
|
|
69
|
+
#
|
|
70
|
+
# enum :status, %w[draft published archived], default: "draft"
|
|
71
|
+
#
|
|
72
|
+
# It is validated, the request schema accepts only those values,
|
|
73
|
+
# TypeScript gets "draft" | "published" | "archived", and it adds
|
|
74
|
+
#
|
|
75
|
+
# Post.statuses # => ["draft", "published", "archived"]
|
|
76
|
+
# post.published? # status == "published"
|
|
77
|
+
# post.published! # update(status: "published")
|
|
78
|
+
# Post.published # where(status: "published"), chainable
|
|
79
|
+
#
|
|
80
|
+
# New records start at `default:`. Without one the value is required,
|
|
81
|
+
# unless null: true. When a helper would clash with another method,
|
|
82
|
+
# prefix: true names them status_published? etc. (prefix: "is" →
|
|
83
|
+
# is_published?); scopes: false skips the datasets.
|
|
84
|
+
def enum(name, values, default: nil, null: false, prefix: nil, scopes: true)
|
|
85
|
+
name = name.to_sym
|
|
86
|
+
values = Array(values).map(&:to_s).uniq.freeze
|
|
87
|
+
raise ArgumentError, "enum #{name}: give at least one value" if values.empty?
|
|
88
|
+
|
|
89
|
+
default = default&.to_s
|
|
90
|
+
if default && !values.include?(default)
|
|
91
|
+
raise ArgumentError, "enum #{name}: the default #{default.inspect} isn't one of #{values.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
field(name, :string, null: null, enum: values, **(default ? { default: default } : {}))
|
|
95
|
+
gemstack_enums[name] = Enum.new(name, values, default)
|
|
96
|
+
define_enum_helpers(name, values, prefix, scopes)
|
|
97
|
+
end
|
|
98
|
+
|
|
61
99
|
def gemstack_validations
|
|
62
100
|
@gemstack_validations ||=
|
|
63
101
|
superclass.respond_to?(:gemstack_validations) ? superclass.gemstack_validations.dup : []
|
|
@@ -104,6 +142,38 @@ module GemStack
|
|
|
104
142
|
|
|
105
143
|
private
|
|
106
144
|
|
|
145
|
+
def define_enum_helpers(name, values, prefix, scopes)
|
|
146
|
+
prefix = { true => "#{name}_", nil => "", false => "" }.fetch(prefix) { "#{prefix}_" }
|
|
147
|
+
enum_method(name, Inflector.pluralize(name.to_s), singleton: true) { values }
|
|
148
|
+
values.each do |value|
|
|
149
|
+
method = "#{prefix}#{value.gsub(/\W+/, "_")}"
|
|
150
|
+
unless method.match?(/\A[a-z_][A-Za-z0-9_]*\z/)
|
|
151
|
+
raise ArgumentError, "enum #{name}: #{value.inspect} can't be a method name; use prefix:"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
enum_method(name, "#{method}?") { self[name] == value }
|
|
155
|
+
enum_method(name, "#{method}!") { update(name => value) }
|
|
156
|
+
enum_method(name, method, singleton: true) { where(name => value) } if scopes
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Defines one helper, refusing to replace an existing method (e.g. a
|
|
161
|
+
# value "new" would replace Model.new: use prefix:).
|
|
162
|
+
def enum_method(name, method, singleton: false, &body)
|
|
163
|
+
taken = singleton ? respond_to?(method, true) : method_defined?(method) || private_method_defined?(method)
|
|
164
|
+
if taken
|
|
165
|
+
raise ArgumentError, "enum #{name}: #{singleton ? "#{self}." : "#"}#{method} already exists; " \
|
|
166
|
+
"use prefix: true (or prefix: \"…\")"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if singleton
|
|
170
|
+
define_singleton_method(method, &body)
|
|
171
|
+
dataset_module { define_method(method, &body) } unless method == Inflector.pluralize(name.to_s)
|
|
172
|
+
else
|
|
173
|
+
define_method(method, &body)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
107
177
|
# PostgreSQL's jsonb comes back as Hash/Array (pg_json); MySQL JSON and
|
|
108
178
|
# SQLite text come back as strings, so those fields are (de)serialized.
|
|
109
179
|
def serialize_json(name)
|
|
@@ -144,6 +214,8 @@ module GemStack
|
|
|
144
214
|
end
|
|
145
215
|
end
|
|
146
216
|
|
|
217
|
+
Enum = Data.define(:name, :values, :default)
|
|
218
|
+
|
|
147
219
|
alias update! update
|
|
148
220
|
|
|
149
221
|
# Identifies this version of the record, for ETags (Controller#stale?) and
|
|
@@ -171,6 +243,14 @@ module GemStack
|
|
|
171
243
|
|
|
172
244
|
private
|
|
173
245
|
|
|
246
|
+
# New records start at their enums' defaults.
|
|
247
|
+
def initialize_set(values)
|
|
248
|
+
super
|
|
249
|
+
self.class.gemstack_enums.each_value do |enum|
|
|
250
|
+
@values[enum.name] = enum.default if enum.default && !@values.key?(enum.name)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
174
254
|
def validate_fields
|
|
175
255
|
self.class.gemstack_fields.each_value do |field|
|
|
176
256
|
opts = field.options
|
|
@@ -197,9 +277,9 @@ module GemStack
|
|
|
197
277
|
words = { gt: "greater than", gte: "greater than or equal to", lt: "less than", lte: "less than or equal to" }
|
|
198
278
|
validates_operator(operator, opts[key], name, message: "must be #{words[key]} #{opts[key]}", allow_nil: true)
|
|
199
279
|
end
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
280
|
+
allowed = opts[:enum] || opts[:in]
|
|
281
|
+
if allowed
|
|
282
|
+
validates_includes(allowed, name, message: "must be one of: #{allowed.to_a.join(", ")}", allow_nil: true)
|
|
203
283
|
end
|
|
204
284
|
validates_format(opts[:format], name, message: "is invalid", allow_nil: true) if opts[:format]
|
|
205
285
|
end
|
data/lib/gemstack/db/testing.rb
CHANGED
|
@@ -47,7 +47,8 @@ module GemStack
|
|
|
47
47
|
|
|
48
48
|
def sample_value(model, field, sequence)
|
|
49
49
|
opts = field.options
|
|
50
|
-
|
|
50
|
+
allowed = opts.values_at(:enum, :in).compact.first
|
|
51
|
+
return Array(allowed).first if allowed
|
|
51
52
|
|
|
52
53
|
case field.type
|
|
53
54
|
when :string, :text then sample_string(field, opts, sequence)
|
data/lib/gemstack/errors.rb
CHANGED
|
@@ -83,8 +83,18 @@ module GemStack
|
|
|
83
83
|
class ValidationError < Error
|
|
84
84
|
status 422, "validation_failed", "Validation failed"
|
|
85
85
|
|
|
86
|
+
# Without a message, it lists the errors — "Validation failed: name is
|
|
87
|
+
# required, price must be greater than 0" — so logs, consoles and clients
|
|
88
|
+
# that only show the message say what failed.
|
|
86
89
|
def initialize(message = nil, errors: {}, **)
|
|
87
|
-
super(message, details: errors, **)
|
|
90
|
+
super(message || self.class.summary(errors), details: errors, **)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.summary(errors)
|
|
94
|
+
list = errors.to_h.flat_map do |field, messages|
|
|
95
|
+
Array(messages).map { |text| %w[base _base].include?(field.to_s) ? text : "#{field} #{text}" }
|
|
96
|
+
end
|
|
97
|
+
list.empty? ? default_message : "#{default_message}: #{list.join(", ")}"
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
def errors = details
|
data/lib/gemstack/interlock.rb
CHANGED
|
@@ -28,6 +28,14 @@ module GemStack
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Runs application code outside a request (e.g. realtime handlers).
|
|
32
|
+
def shared
|
|
33
|
+
acquire_shared
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
release_shared
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
def exclusive
|
|
32
40
|
@mutex.synchronize do
|
|
33
41
|
@waiting_writers += 1
|
data/lib/gemstack/reloader.rb
CHANGED
data/lib/gemstack/schema.rb
CHANGED
|
@@ -10,6 +10,7 @@ module GemStack
|
|
|
10
10
|
# required :name, :string, max_length: 120
|
|
11
11
|
# required :price, :decimal, gt: 0
|
|
12
12
|
# optional :active, :boolean, default: true
|
|
13
|
+
# optional :status, :string, enum: %w[draft published] # "draft" | "published" in TypeScript
|
|
13
14
|
# optional :tags, [:string]
|
|
14
15
|
# optional :dimensions do
|
|
15
16
|
# required :width, :integer
|
|
@@ -33,7 +34,7 @@ module GemStack
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
NO_DEFAULT = Object.new.freeze
|
|
36
|
-
RULES = %i[gt gte lt lte min_length max_length in format].freeze
|
|
37
|
+
RULES = %i[gt gte lt lte min_length max_length in enum format].freeze
|
|
37
38
|
STRING_TYPES = %i[string text].freeze
|
|
38
39
|
|
|
39
40
|
# rule => [passes?(value, arg), message(arg)]
|
|
@@ -45,6 +46,7 @@ module GemStack
|
|
|
45
46
|
min_length: [->(v, a) { v.to_s.length >= a }, ->(a) { "is too short (minimum #{a} characters)" }],
|
|
46
47
|
max_length: [->(v, a) { v.to_s.length <= a }, ->(a) { "is too long (maximum #{a} characters)" }],
|
|
47
48
|
in: [->(v, a) { a.include?(v) }, ->(a) { "must be one of: #{a.to_a.join(", ")}" }],
|
|
49
|
+
enum: [->(v, a) { a.include?(v) }, ->(a) { "must be one of: #{a.to_a.join(", ")}" }], # + a TS union
|
|
48
50
|
format: [->(v, a) { a.match?(v.to_s) }, ->(_) { "is invalid" }]
|
|
49
51
|
}.freeze
|
|
50
52
|
|
data/lib/gemstack/version.rb
CHANGED
data/templates/app/Gemfile.tt
CHANGED
|
@@ -14,9 +14,9 @@ gem "puma", ">= 6.4"
|
|
|
14
14
|
<% if database? -%>
|
|
15
15
|
gem "<%= driver_gem %>", "<%= driver_version %>" # the <%= database_adapter %> driver (config/database.yml)
|
|
16
16
|
<% end -%>
|
|
17
|
+
gem "brotli", "~> 0.8" # Brotli response compression for browsers that accept it (gzip otherwise)
|
|
17
18
|
|
|
18
19
|
# Any gem works here as usual, e.g.:
|
|
19
|
-
# gem "brotli" # Brotli compression (gzip is used without it)
|
|
20
20
|
# gem "redis-client" # shared cache: config.cache.store = :redis
|
|
21
21
|
# gem "stripe"
|
|
22
22
|
|
|
@@ -34,7 +34,8 @@ GemStack.configure do |config|
|
|
|
34
34
|
# config.http.pagination.max_per_page = 100 # the most a client may ask for with ?per_page=
|
|
35
35
|
|
|
36
36
|
# ── Responses ───────────────────────────────────────────────────────────
|
|
37
|
-
# config.http.compression.enabled = true # gzip/
|
|
37
|
+
# config.http.compression.enabled = true # Brotli/gzip by Accept-Encoding, for text/JSON over 1 KB
|
|
38
|
+
# config.http.compression.encodings = %w[br gzip] # preference order; Brotli uses the brotli gem
|
|
38
39
|
# config.http.etags = true # ETag + 304 Not Modified for GET responses
|
|
39
40
|
|
|
40
41
|
# ── Logging ─────────────────────────────────────────────────────────────
|
|
@@ -15,7 +15,10 @@ test:
|
|
|
15
15
|
<<: *default
|
|
16
16
|
database: db/test.sqlite3
|
|
17
17
|
|
|
18
|
-
#
|
|
18
|
+
# Production: use PostgreSQL. Add gem "pg" and set
|
|
19
|
+
# DATABASE_URL=postgres://user:password@host:5432/<%= database_name %>_production
|
|
20
|
+
# which overrides this section (hosting platforms set it for you). SQLite below
|
|
21
|
+
# suits a single server only: keep the file on a persistent volume and back it up.
|
|
19
22
|
production:
|
|
20
23
|
<<: *default
|
|
21
24
|
database: db/production.sqlite3
|
|
@@ -103,6 +103,8 @@ env:
|
|
|
103
103
|
<% if sqlite? -%>
|
|
104
104
|
|
|
105
105
|
# The SQLite file lives on this volume, shared by every role: keep them on one server.
|
|
106
|
+
# For production, PostgreSQL is recommended (docs/database.md): switch the app to it
|
|
107
|
+
# and `gemstack generate deploy --force` adds the database accessory.
|
|
106
108
|
volumes:
|
|
107
109
|
- "<%= service %>_data:/data"
|
|
108
110
|
<% end -%>
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
# listed here is refused. `*` matches one segment and is passed
|
|
5
|
-
# together with the request (cookies, headers)
|
|
3
|
+
# What browsers may do over realtime connections (docs/realtime.md).
|
|
4
|
+
# Anything not listed here is refused. `*` matches one segment and is passed
|
|
5
|
+
# to the block, together with the connection's request (cookies, headers).
|
|
6
6
|
#
|
|
7
|
-
# GemStack.broadcast("orders:#{order.id}", "order.updated", order)
|
|
7
|
+
# GemStack.broadcast("orders:#{order.id}", "order.updated", order) # server → browsers
|
|
8
8
|
#
|
|
9
9
|
GemStack.channels do
|
|
10
|
+
# Who is connecting, once per connection (nil: anonymous). A Hash with an
|
|
11
|
+
# :id is also the presence metadata others see. With `gemstack add auth`:
|
|
12
|
+
# identify { |request| GemStack::Auth.user_from(request)&.then { |u| { id: u.id, name: u.name } } }
|
|
13
|
+
|
|
10
14
|
# channel "announcements" # public
|
|
11
15
|
# channel "orders:*" do |order_id, request| # private
|
|
12
|
-
# Order.find_by(id: order_id)&.user_id ==
|
|
16
|
+
# Order.find_by(id: order_id)&.user_id == identity(request)&.fetch(:id)
|
|
17
|
+
# end
|
|
18
|
+
# channel "rooms:*", presence: true do |room_id, request| # + who's here (usePresence)
|
|
19
|
+
# !identity(request).nil?
|
|
20
|
+
# end
|
|
21
|
+
|
|
22
|
+
# Messages browsers send with realtime.send(channel, event, data) — only to
|
|
23
|
+
# channels they're subscribed to. The return value is the reply.
|
|
24
|
+
# receive "rooms:*" do |message|
|
|
25
|
+
# post = Post.create!(room_id: message.params.first, body: message.data["body"], user_id: message.identity[:id])
|
|
26
|
+
# GemStack.broadcast(message.channel, "post.created", post)
|
|
27
|
+
# { id: post.id }
|
|
13
28
|
# end
|
|
14
29
|
end
|