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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3653be40ea53434b0176c572f0ecdba840c2af0b639a7d4ed64a9117037c72f4
4
- data.tar.gz: 2a6fe58ab592b81ee4ecd2bbe794a9e6e94f1b693afa6963725f6ac1a0719f93
3
+ metadata.gz: 6b4da4865f171f049ffb5861de51b0f9e376e8e684dac8c897c760194b1269ad
4
+ data.tar.gz: 678190d65346a0542c88f0e977227c477666d98c92bf252eb5d03cae23116796
5
5
  SHA512:
6
- metadata.gz: f0059eae0a47937b5984e631adfd2430e0b261845f4d6165f3288d269b63cd3d9601b89b4b0efbde33933e678128350c4bdab767ed7cf8927d0e2272691beaee
7
- data.tar.gz: 169b95e87f103a9d21a07e60b51da63da756381bdc541d9e57abf0224454e364d4d1d64c931398f3f702b545f4f5463be30eeefe7468ec899651ac1c63c04d8d
6
+ metadata.gz: 6d16bf203a45f8b40a37dd13cac7d31c983b5f71ea84c6ae90d5dc566f306b650b6ab3410edcfe1c21248f0accf3af9870db4f57567727a0395678beb65d6897
7
+ data.tar.gz: 392c2b983fb86297436e835136c92fc37c19fe6649bb55b3ea5c7f9d0e058849e397634ebcd612806d264f27edaced07ed55689c4b1504e28d0abcfe5ed8c531
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
3
7
  ## 0.3.6
4
8
 
5
9
  See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # gemstack
2
2
 
3
- GemStack: a fast, modular Ruby API framework for Next.js applications.
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 API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed
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
@@ -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
- ok = unbundled { system("bin/gemstack", "db:create", chdir: destination, out: File::NULL, err: File::NULL) }
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
- "check config/database.yml (or set DATABASE_URL in .env), then run `gemstack db:create`")
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
@@ -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
- "keep the file on a persistent volume and run a single server; back it up")
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
- [field.name, field.type, ("optional" if field.optional), ("unique" if field.unique),
137
- ("index" if field.index && !field.reference?)].compact.join(":")
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...]. Fields are required (NOT NULL)
12
- # unless marked :optional. Booleans default to false. Modifiers: optional,
13
- # unique, index.
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
- def required? = !optional && type != "boolean"
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.3.6 # a specific version
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
- { name: attr[:name].to_s, type: type_ref(attr[:type]), nullable: attr[:nullable], optional: false }
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 ? { object: schema_fields(field.schema) } : { scalar: field.type }
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}>"
@@ -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:, format:. Types are GemStack::Types.
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
- if opts[:in]
201
- validates_includes(opts[:in], name, message: "must be one of: #{opts[:in].to_a.join(", ")}",
202
- allow_nil: true)
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
@@ -47,7 +47,8 @@ module GemStack
47
47
 
48
48
  def sample_value(model, field, sequence)
49
49
  opts = field.options
50
- return Array(opts[:in]).first if opts[:in]
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)
@@ -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
@@ -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
@@ -14,7 +14,7 @@ module GemStack
14
14
  @app = app
15
15
  @application = application
16
16
  @watcher = Dev::FileWatcher.new(WATCHED, root: application.root)
17
- @interlock = Interlock.new
17
+ @interlock = application.interlock
18
18
  @check = Mutex.new
19
19
  end
20
20
 
@@ -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
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module GemStack
4
4
  # All GemStack gems are released together under a single version.
5
- VERSION = "0.3.6"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -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/Brotli responses over 1 KB (add gem "brotli" for br)
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
- # Keep the file on persistent storage (a volume); or set DATABASE_URL=sqlite3:/data/app.sqlite3.
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
- # Realtime channels browsers may subscribe to (docs/realtime.md). Anything not
4
- # listed here is refused. `*` matches one segment and is passed to the block,
5
- # together with the request (cookies, headers) for authorization.
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 == current_user_id(request)
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