studio-engine 0.92.2 → 0.93.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 +22 -0
- data/README.md +1 -1
- data/app/controllers/concerns/studio/error_handling.rb +15 -0
- data/app/controllers/studio/onboarding_controller.rb +4 -6
- data/app/models/concerns/sluggable.rb +152 -2
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3769d755a39684b1488e69948a2c671c736c655400bf035d6c69e8db5c990a8e
|
|
4
|
+
data.tar.gz: 2941057ba862ce883d57e58f56d9dfae793c6ad0781802af6147848583604ecf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 443ae3e30df171f7ff4614a82785a0e40f231dc11d792d3e36a8160b9d13d112bd1e55fc420759a564b02ec6b69d0f64406c9d7265eca2bf2885edb8f7be58a4
|
|
7
|
+
data.tar.gz: 7f1b7c14d366cb9cd26f7c0a3caf8c5135d13ac79b359e8ca095047602657bf3a4eb28d5127fe13fcb5175137fe9fbb8844b370b89c13340bdbf4540679b8cc0
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.93.0 — 2026-10-07
|
|
8
|
+
|
|
7
9
|
## 0.92.2 — 2026-10-07
|
|
8
10
|
|
|
9
11
|
## 0.92.1 — 2026-10-07
|
|
@@ -22,6 +24,26 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
22
24
|
raises `Studio::HostSchemaError` in development and test, and logs (and reports
|
|
23
25
|
to Sentry) in production; `Studio.host_schema_check = :raise | :log | false`
|
|
24
26
|
overrides. Extra host columns are never reported, and rake tasks skip it.
|
|
27
|
+
- **`Sluggable#rename_slug!` changes a slug on purpose and cascades.** It updates
|
|
28
|
+
the row and every child column that holds the old slug in one transaction:
|
|
29
|
+
each `has_many`/`has_one` declared with `primary_key: :slug`, plus the pairs a
|
|
30
|
+
model declares with `has_slug_children "table" => :column`. A blank, badly
|
|
31
|
+
formed (`slug_format`, parameterize-shaped by default) or taken slug raises
|
|
32
|
+
`Sluggable::SlugRefused`, a `RecordInvalid` with the reason on
|
|
33
|
+
`errors[:slug]`; `rename_slug` returns false instead.
|
|
34
|
+
- **`Studio::ErrorHandling` answers a refused slug rename with 422** and the
|
|
35
|
+
reason (JSON and other formats), or a redirect back with the reason as the
|
|
36
|
+
alert (HTML), never the 500 catch-all and no ErrorLog row.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **A slug is written once, at create.** `Sluggable` no longer recomputes
|
|
41
|
+
`slug` from `name_slug` on every save, so a name edit stops renaming the row
|
|
42
|
+
and orphaning the child rows that point at it. A persisted row with a blank
|
|
43
|
+
slug still gets one on its next save, and a `name_slug` that reads the id is
|
|
44
|
+
settled inside the create, so `user-<id>` no longer waits for a second save.
|
|
45
|
+
Code that relied on a save moving the slug calls `rename_slug!(name_slug)`
|
|
46
|
+
instead.
|
|
25
47
|
|
|
26
48
|
### Fixed
|
|
27
49
|
|
data/README.md
CHANGED
|
@@ -35,7 +35,7 @@ resolved.
|
|
|
35
35
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
36
36
|
- **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
|
|
37
37
|
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
|
|
38
|
-
- **Sluggable concern**:
|
|
38
|
+
- **Sluggable concern**: a human-readable slug written once at create (`to_param` returns it); `rename_slug!` changes it and cascades to every child column in one transaction, and a refused rename answers 422
|
|
39
39
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
40
40
|
- **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
|
|
41
41
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
@@ -17,6 +17,8 @@ module Studio
|
|
|
17
17
|
# handlers BELOW this line, never above it.
|
|
18
18
|
rescue_from StandardError, with: :handle_unexpected_error
|
|
19
19
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
20
|
+
# A string, so including this concern does not load the model concern.
|
|
21
|
+
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
20
22
|
|
|
21
23
|
before_action :require_authentication
|
|
22
24
|
|
|
@@ -243,6 +245,19 @@ module Studio
|
|
|
243
245
|
raise exception
|
|
244
246
|
end
|
|
245
247
|
|
|
248
|
+
# Layer 1: a refused slug rename (Sluggable#rename_slug!) is the caller's
|
|
249
|
+
# input, not a fault: 422 with the reason for JSON and other formats, and a
|
|
250
|
+
# redirect back with the reason as the alert for HTML. No ErrorLog row.
|
|
251
|
+
def handle_slug_refused(exception)
|
|
252
|
+
reason = exception.record.errors.full_messages_for(:slug).to_sentence.presence || exception.message
|
|
253
|
+
|
|
254
|
+
respond_to do |format|
|
|
255
|
+
format.html { redirect_back_or_to root_path, alert: reason }
|
|
256
|
+
format.json { render json: { error: reason }, status: :unprocessable_entity }
|
|
257
|
+
format.any { render plain: reason, status: :unprocessable_entity }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
246
261
|
# Layer 1: Catch-all for unexpected errors — log + friendly response.
|
|
247
262
|
# Skips logging if rescue_and_log already captured it.
|
|
248
263
|
def handle_unexpected_error(exception)
|
|
@@ -79,12 +79,10 @@ module Studio
|
|
|
79
79
|
# 2. It steps around any host before_save that DERIVES first_name FROM
|
|
80
80
|
# name — set_name_parts does exactly that — which would discard the
|
|
81
81
|
# value we were just handed.
|
|
82
|
-
# 3.
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# a unique index, for every signup. That is the constraint this
|
|
87
|
-
# endpoint has always been protecting; it is not a shortcut.
|
|
82
|
+
# 3. A host save can carry other callbacks that react to a name
|
|
83
|
+
# write; this endpoint writes only the name columns. (Sluggable
|
|
84
|
+
# writes the slug once, at create, so the slug stays put either
|
|
85
|
+
# way.)
|
|
88
86
|
#
|
|
89
87
|
# WHAT SKIPPING CALLBACKS USED TO COST. Because set_name_parts never
|
|
90
88
|
# ran, `first_name` got the WHOLE typed value: someone answering "Ada
|
|
@@ -1,17 +1,167 @@
|
|
|
1
|
+
# A human-readable `slug` that is written once and changed only on purpose.
|
|
2
|
+
#
|
|
3
|
+
# The slug is set at create from `name_slug` (each including model defines it)
|
|
4
|
+
# and never recomputed by a later save, because other tables point at it by
|
|
5
|
+
# value: a display-name edit must not rename the row and orphan its children.
|
|
6
|
+
# A persisted row whose slug is blank gets one on its next save, since filling a
|
|
7
|
+
# blank is still the first write. A `name_slug` that reads the id (`user-<id>`)
|
|
8
|
+
# is settled once more right after the insert, inside the create, because the
|
|
9
|
+
# id does not exist when before_save runs.
|
|
10
|
+
#
|
|
11
|
+
# The one way to change a persisted slug is `rename_slug!` (or `rename_slug`),
|
|
12
|
+
# which updates the row and every child column that references it in one
|
|
13
|
+
# transaction. The children are found two ways:
|
|
14
|
+
#
|
|
15
|
+
# * every `has_many` / `has_one` on the model with `primary_key: :slug`;
|
|
16
|
+
# * every pair declared with `has_slug_children`, for a table that has no
|
|
17
|
+
# association here:
|
|
18
|
+
#
|
|
19
|
+
# class Person < ApplicationRecord
|
|
20
|
+
# include Sluggable
|
|
21
|
+
# has_slug_children "athletes" => :person_slug,
|
|
22
|
+
# "news" => %i[primary_person_slug secondary_person_slug]
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The declaration lives on the parent so the cascade never depends on whether a
|
|
26
|
+
# child class happens to be loaded.
|
|
27
|
+
#
|
|
28
|
+
# A refusal (blank, badly formed, or taken) raises Sluggable::SlugRefused, a
|
|
29
|
+
# subclass of ActiveRecord::RecordInvalid, with the reason on `errors[:slug]`.
|
|
30
|
+
# Studio::ErrorHandling answers it with 422 and the reason, never a 500, and
|
|
31
|
+
# `rename_slug` returns false instead of raising, for an inline form error.
|
|
1
32
|
module Sluggable
|
|
2
33
|
extend ActiveSupport::Concern
|
|
3
34
|
|
|
35
|
+
# Lowercase words of letters and digits joined by single hyphens: the shape
|
|
36
|
+
# `String#parameterize` produces. A model whose slugs legitimately hold other
|
|
37
|
+
# characters sets its own `self.slug_format`.
|
|
38
|
+
DEFAULT_SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
39
|
+
|
|
40
|
+
# The refusal rename_slug! raises: a RecordInvalid, so a caller that already
|
|
41
|
+
# rescues RecordInvalid keeps working, with a class of its own so the
|
|
42
|
+
# controller layer can answer it with 422 without claiming every invalid save.
|
|
43
|
+
class SlugRefused < ActiveRecord::RecordInvalid; end
|
|
44
|
+
|
|
4
45
|
included do
|
|
5
|
-
|
|
46
|
+
class_attribute :slug_format, instance_writer: false, default: DEFAULT_SLUG_FORMAT
|
|
47
|
+
class_attribute :declared_slug_children, instance_writer: false, default: [].freeze
|
|
48
|
+
|
|
49
|
+
before_save :set_slug, if: :sluggable_unwritten?
|
|
50
|
+
after_create :sluggable_settle_derived_slug
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class_methods do
|
|
54
|
+
# Declares child columns that hold this model's slug: a hash of table name to
|
|
55
|
+
# one column or a list of columns. Repeatable; each call adds to the set.
|
|
56
|
+
def has_slug_children(pairs)
|
|
57
|
+
added = pairs.flat_map do |table, columns|
|
|
58
|
+
Array(columns).map { |column| [table.to_s, column.to_s].freeze }
|
|
59
|
+
end
|
|
60
|
+
self.declared_slug_children = (declared_slug_children + added).uniq.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Every [table, column] pair `rename_slug!` updates: the slug-keyed
|
|
64
|
+
# associations plus the declared pairs, without duplicates.
|
|
65
|
+
def slug_children
|
|
66
|
+
from_associations = reflect_on_all_associations.filter_map do |reflection|
|
|
67
|
+
next unless %i[has_many has_one].include?(reflection.macro)
|
|
68
|
+
next if reflection.through_reflection? || reflection.polymorphic? || reflection.options[:as]
|
|
69
|
+
next unless reflection.options[:primary_key].to_s == "slug"
|
|
70
|
+
|
|
71
|
+
[reflection.klass.table_name, reflection.foreign_key.to_s].freeze
|
|
72
|
+
end
|
|
73
|
+
(from_associations + declared_slug_children).uniq
|
|
74
|
+
end
|
|
6
75
|
end
|
|
7
76
|
|
|
8
77
|
def to_param
|
|
9
78
|
slug
|
|
10
79
|
end
|
|
11
80
|
|
|
81
|
+
# Changes this row's slug to `new_slug` and rewrites every child column that
|
|
82
|
+
# held the old one, all or nothing. Returns { "table.column" => rows updated }.
|
|
83
|
+
# Raises Sluggable::SlugRefused, with the reason on errors[:slug], when
|
|
84
|
+
# the slug is blank, badly formed, or already taken.
|
|
85
|
+
def rename_slug!(new_slug)
|
|
86
|
+
raise ActiveRecord::RecordNotSaved.new("a slug can be renamed only on a saved record", self) unless persisted?
|
|
87
|
+
|
|
88
|
+
new_slug = new_slug.to_s.strip
|
|
89
|
+
old_slug = slug_in_database
|
|
90
|
+
errors.delete(:slug)
|
|
91
|
+
return {} if new_slug == old_slug
|
|
92
|
+
|
|
93
|
+
sluggable_refuse!(:blank) if new_slug.empty?
|
|
94
|
+
sluggable_refuse!(:invalid) unless slug_format.match?(new_slug)
|
|
95
|
+
sluggable_refuse!(:taken, value: new_slug) if sluggable_taken?(new_slug)
|
|
96
|
+
|
|
97
|
+
renamed = { slug: new_slug }
|
|
98
|
+
renamed[:updated_at] = Time.current if has_attribute?(:updated_at)
|
|
99
|
+
counts = {}
|
|
100
|
+
self.class.transaction(requires_new: true) do
|
|
101
|
+
self.class.base_class.unscoped.where(self.class.primary_key => id).update_all(renamed)
|
|
102
|
+
self.class.slug_children.each do |table, column|
|
|
103
|
+
counts["#{table}.#{column}"] = sluggable_cascade(table, column, old_slug, new_slug)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
renamed.each { |name, value| write_attribute(name, value) }
|
|
107
|
+
clear_attribute_changes(renamed.keys)
|
|
108
|
+
counts
|
|
109
|
+
rescue ActiveRecord::RecordNotUnique
|
|
110
|
+
# The pre-check passed and a concurrent write took the slug first; the
|
|
111
|
+
# unique index is the arbiter, and the answer is the same refusal.
|
|
112
|
+
sluggable_refuse!(:taken, value: new_slug)
|
|
113
|
+
rescue ActiveRecord::InvalidForeignKey
|
|
114
|
+
sluggable_refuse!(:invalid, message: "cannot change while a constraint without ON UPDATE CASCADE references it")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# rename_slug! for a form: false, with the reason on errors[:slug], instead of
|
|
118
|
+
# raising.
|
|
119
|
+
def rename_slug(new_slug)
|
|
120
|
+
rename_slug!(new_slug)
|
|
121
|
+
true
|
|
122
|
+
rescue SlugRefused
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
12
126
|
private
|
|
13
127
|
|
|
128
|
+
def sluggable_unwritten?
|
|
129
|
+
new_record? || slug.blank?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A model may override this; the override owns its slug, and the post-insert
|
|
133
|
+
# settle below leaves it alone.
|
|
14
134
|
def set_slug
|
|
15
|
-
self.slug = name_slug
|
|
135
|
+
self.slug = @sluggable_derived_slug = name_slug
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sluggable_settle_derived_slug
|
|
139
|
+
derived = @sluggable_derived_slug
|
|
140
|
+
@sluggable_derived_slug = nil
|
|
141
|
+
return if derived.nil? || slug != derived
|
|
142
|
+
|
|
143
|
+
settled = name_slug
|
|
144
|
+
update_column(:slug, settled) if settled.present? && settled != slug
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def sluggable_taken?(candidate)
|
|
148
|
+
self.class.base_class.unscoped.where(slug: candidate).where.not(self.class.primary_key => id).exists?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# One UPDATE on the parent's connection: the cascade a foreign key with
|
|
152
|
+
# ON UPDATE CASCADE would run, and it needs no model for the child table.
|
|
153
|
+
def sluggable_cascade(table, column, old_slug, new_slug)
|
|
154
|
+
connection = self.class.connection
|
|
155
|
+
quoted_column = connection.quote_column_name(column)
|
|
156
|
+
connection.update(<<~SQL.squish, "Sluggable cascade")
|
|
157
|
+
UPDATE #{connection.quote_table_name(table)}
|
|
158
|
+
SET #{quoted_column} = #{connection.quote(new_slug)}
|
|
159
|
+
WHERE #{quoted_column} = #{connection.quote(old_slug)}
|
|
160
|
+
SQL
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sluggable_refuse!(reason, **options)
|
|
164
|
+
errors.add(:slug, reason, **options)
|
|
165
|
+
raise SlugRefused, self
|
|
16
166
|
end
|
|
17
167
|
end
|
data/lib/studio/version.rb
CHANGED