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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e0b538d638180facefc1cff85cc4c5ccbea6aa49711d6ac48f1863a87e8d776
4
- data.tar.gz: c48c355f0e8e9e2164649383eb61c7f47eaece289dfd0007e1f4f2d849137023
3
+ metadata.gz: 3769d755a39684b1488e69948a2c671c736c655400bf035d6c69e8db5c990a8e
4
+ data.tar.gz: 2941057ba862ce883d57e58f56d9dfae793c6ad0781802af6147848583604ecf
5
5
  SHA512:
6
- metadata.gz: 9ac3c8a86695295f537aaa8355bfdc393a3b6a7ff5409308b7fe6ab402021e145089adc5a212d44aaced6921904563ccb16cf838ec5c46a33f7c9850196e2dda
7
- data.tar.gz: 6d0fbbc3746e31d7dfd570185740ce394f91fb5503e5c3ce6f0ad7d59c4a6ba4f302669d8fd96e4bd1e6c2ef509aa5703951b8417381411bfb5d276cc8607e1d
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**: `before_save :set_slug` with `to_param` for human-readable URLs
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. THE SLUG. Sluggable's `before_save :set_slug` is UNGATED, and
83
- # mcritchie-studio's User#name_slug is built from `name`. A full
84
- # save right after this writes `name` would therefore re-point the
85
- # slug the account answers on — a URL change, on a column carrying
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
- before_save :set_slug
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.92.2"
2
+ VERSION = "0.93.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.92.2
4
+ version: 0.93.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie