studio-engine 0.69.3 → 0.69.4

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: 5ade07348f1d55403ac332ea2a030b8af81ff8b9b8d4a49b637326eb40168847
4
- data.tar.gz: e5ee5053749871945628e8f522fffee01569e0ae3052016b9388c79b98be59d4
3
+ metadata.gz: 12d0b8a6c255a4f9e18d6a3a588abefdd7dd5d54a3801deb9b52eb99da2d78fe
4
+ data.tar.gz: 3467bf1af09d79805053d721c1d0d6028fd0c5b0f806c94d6a2cdc7864f048f1
5
5
  SHA512:
6
- metadata.gz: baf91b4f29446f4cc53ef15890ba67e9691046c769af46ac3beec04f9ad530ad3f67ee9d4d686d85d37ed3b0f7e78c1e453eec0476e4146eaa97bbd8fdf4fd68
7
- data.tar.gz: 5ae6368f5e8c1ba95081f8608acc3193e3b82436e42e6a2cef059a6e979fb1b025da56058f980bc544f7d0add642d2972d7aefcc5b37c1fda22ca65c71c269cf
6
+ metadata.gz: 3fd8ea14026738113f2348ea267b2cde306afa6a37d1b37dbf23e426013cb632be62164413e64516893f913e9f8a1618559ef326187e328001d6effceffe4187
7
+ data.tar.gz: 1498813d632e019fc574a6467e87fa73965f0e018001c845cac52a22d21443cfad8426e50a94ec9d37e874a6f1d059770dd97696665141853fb2a9c458b97242
data/CHANGELOG.md CHANGED
@@ -55,6 +55,34 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
55
55
 
56
56
  ### Fixed
57
57
 
58
+ - **Onboarding no longer stores a two-word answer as the whole first name.**
59
+ `Studio::OnboardingController#first_name` writes with `update_columns`, which
60
+ skips callbacks — so the host's `before_save :set_name_parts` never ran and
61
+ someone answering "Ada Lovelace" landed `first_name = "Ada Lovelace"`,
62
+ `last_name` NULL. It never self-healed either: `set_name_parts` is gated on
63
+ `name_changed?`, and no later save sees a name change. The derivation now
64
+ comes to the writer instead — a new pure primitive, **`Studio::NameParts.from`**
65
+ (`lib/studio/name_parts.rb`), carrying the same rule the host callback runs,
66
+ including the deliberate OMISSION of `last_name` for a one-word name so a
67
+ surname already on file survives a first-name prompt.
68
+ **`update_columns` STAYS**, and the fix does not touch it: `Sluggable`'s
69
+ `before_save :set_slug` is UNGATED and mcritchie-studio's `User#name_slug` is
70
+ built from `name`, so a full save here would re-point the slug the account
71
+ answers on — on a uniquely-indexed column, at every signup.
72
+ `test/integration/onboarding_name_parts_test.rb` carries that as a live
73
+ control (`update!` moves `pat-pat@example.com` → `ada-lovelace-pat@…`), not as
74
+ a claim. `last_name` is written only where the column exists: the engine's own
75
+ standard-columns migration does not carry it, so mcritchie-industries,
76
+ moms-app and acquisition-studio would otherwise have taken a
77
+ `MissingAttributeError` 500 on every hyphen-free two-word answer
78
+ (`test/integration/onboarding_thin_host_test.rb`).
79
+ NO DATA REPAIR SHIPS: measured 2026-09-05 across both production databases
80
+ (mcritchie-studio 5 users, turf-monster 43) and both QA ones, **zero rows**
81
+ carry a space in `first_name`. Repairing rows whose halves merely disagree
82
+ with `name` would be data loss — `/profile` writes those columns directly —
83
+ and a fossil repair belongs to the consuming app that owns its `users` table,
84
+ not to the gem.
85
+
58
86
  - **Knowledge layer hardening** — the five findings from the 0.67.0 reviews:
59
87
  upload keys carry a random suffix (same-named uploads in the same second no
60
88
  longer overwrite the first object before the unique index can refuse);
@@ -42,17 +42,35 @@ module Studio
42
42
  end
43
43
 
44
44
  rescue_and_log(target: current_user) do
45
- # update_columns, not update!: this runs seconds after signup, on an
46
- # account that may be mid-onboarding, and a validation failure elsewhere
47
- # on the record (a grandfathered reserved username, say) must not block a
48
- # first name. It also steps around any host before_save that DERIVES
49
- # first_name FROM name turf's set_name_parts does exactly that, and
50
- # writing through it would discard the value we were just handed.
51
- attrs = { first_name: value }
45
+ # update_columns, not update!, for THREE reasons all still true:
46
+ #
47
+ # 1. This runs seconds after signup, on an account that may be
48
+ # mid-onboarding, and a validation failure elsewhere on the record
49
+ # (a grandfathered reserved username, say) must not block a name.
50
+ # 2. It steps around any host before_save that DERIVES first_name FROM
51
+ # name set_name_parts does exactly that — which would discard the
52
+ # value we were just handed.
53
+ # 3. THE SLUG. Sluggable's `before_save :set_slug` is UNGATED, and
54
+ # mcritchie-studio's User#name_slug is built from `name`. A full
55
+ # save right after this writes `name` would therefore re-point the
56
+ # slug the account answers on — a URL change, on a column carrying
57
+ # a unique index, for every signup. That is the constraint this
58
+ # endpoint has always been protecting; it is not a shortcut.
59
+ #
60
+ # WHAT SKIPPING CALLBACKS USED TO COST. Because set_name_parts never
61
+ # ran, `first_name` got the WHOLE typed value: someone answering "Ada
62
+ # Lovelace" landed first_name="Ada Lovelace", last_name NULL — and it
63
+ # never self-healed, because set_name_parts is gated on `name_changed?`
64
+ # and no later save sees a name change. So the derivation comes to the
65
+ # writer instead: Studio::NameParts is the same rule the callback runs.
66
+ attrs = name_columns(value)
52
67
  attrs[:name] = value if current_user.name.blank?
53
68
  current_user.update_columns(attrs)
54
69
 
55
- render json: { ok: true, first_name: value, next: remaining_steps }
70
+ # The STORED first name, not the typed string — they differ for exactly
71
+ # the case above, and a response that disagreed with the row would be
72
+ # the same bug wearing a different hat.
73
+ render json: { ok: true, first_name: attrs[:first_name], next: remaining_steps }
56
74
  end
57
75
  end
58
76
 
@@ -68,6 +86,19 @@ module Studio
68
86
 
69
87
  private
70
88
 
89
+ # The name halves to write, derived exactly as the host's set_name_parts
90
+ # would derive them from the same string.
91
+ #
92
+ # `last_name` is dropped for a host that has no such column —
93
+ # mcritchie-industries' users table is eight columns wide and update_columns
94
+ # on an absent column raises. Same respond_to? guard /profile already
95
+ # carries for the same reason.
96
+ def name_columns(value)
97
+ parts = Studio::NameParts.from(value)
98
+ parts.delete(:last_name) unless current_user.respond_to?(:last_name)
99
+ parts
100
+ end
101
+
71
102
  # What is left AFTER this write. The host's resolver decides; the engine's
72
103
  # default is "nothing further", which is the right answer for an app whose
73
104
  # only onboarding ask is the name.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The two halves of a person's name, derived from the one string they typed.
5
+ #
6
+ # WHY THIS IS A PRIMITIVE AND NOT THREE LINES IN A CONTROLLER. Every Studio
7
+ # host derives `first_name`/`last_name` from `name` in a `before_save`
8
+ # (`set_name_parts` — byte-identical in mcritchie-studio and turf-monster), so
9
+ # any writer that steps around callbacks owes the SAME derivation or the row
10
+ # lands split differently depending on which door it came through. The engine
11
+ # has exactly such a writer: Studio::OnboardingController writes with
12
+ # `update_columns`, and it does so for a reason worth keeping (see there).
13
+ # Parity is the whole requirement, so the rule lives in one named, tested
14
+ # place instead of being retyped at each door.
15
+ #
16
+ # PURE ON PURPOSE — no record, no columns, no ActiveRecord. That is what lets
17
+ # the engine's pure-Ruby unit lane exercise it directly, and what lets a host
18
+ # callback delegate to it later without inheriting anything.
19
+ #
20
+ # `last_name` is OMITTED, not nil, for a one-word name. That is parity, not
21
+ # tidiness: the host callback has always left an existing last name standing
22
+ # (`self.last_name = parts.last if parts.size > 1`), so a hash that nulled it
23
+ # would make the callback-free writer disagree with the callback in the
24
+ # opposite direction — someone with a last name on file would lose it by
25
+ # answering a first-name prompt. Whether that carry-over is itself the right
26
+ # rule is a separate question from this one; matching it is this file's job.
27
+ #
28
+ # Mirrors mcritchie-studio's `User.name_parts`, which the hub extracted for its
29
+ # own callback-free writers (seeds and the identity migrations). The engine
30
+ # cannot call that one — it ships to hosts that do not define it, and
31
+ # mcritchie-industries has no `first_name` column at all — so it carries its
32
+ # own copy of a rule both must agree on. test/lib/studio/name_parts_test.rb
33
+ # pins the parity for every shape of name.
34
+ module NameParts
35
+ module_function
36
+
37
+ # A hash ready for `update_columns` / `assign_attributes`.
38
+ #
39
+ # Studio::NameParts.from("Ada Lovelace") # => { first_name: "Ada", last_name: "Lovelace" }
40
+ # Studio::NameParts.from("Ada") # => { first_name: "Ada" }
41
+ # Studio::NameParts.from("Ada B. Lovelace") # => { first_name: "Ada", last_name: "Lovelace" }
42
+ # Studio::NameParts.from("") # => { first_name: nil }
43
+ def from(name)
44
+ words = name.to_s.strip.split(" ")
45
+ parts = { first_name: words.first }
46
+ parts[:last_name] = words.last if words.size > 1
47
+ parts
48
+ end
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.69.3"
2
+ VERSION = "0.69.4"
3
3
  end
data/lib/studio.rb CHANGED
@@ -14,6 +14,7 @@ require "studio/profile_image"
14
14
  require "studio/oauth_identity"
15
15
  require "studio/newsletter"
16
16
  require "studio/username_generator"
17
+ require "studio/name_parts"
17
18
  require "studio/s3"
18
19
  require "studio/image_cache"
19
20
  require "studio/link_token"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.69.3
4
+ version: 0.69.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-03 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -576,6 +576,7 @@ files:
576
576
  - lib/studio/link_token.rb
577
577
  - lib/studio/log_rotation.rb
578
578
  - lib/studio/mail_transport.rb
579
+ - lib/studio/name_parts.rb
579
580
  - lib/studio/newsletter.rb
580
581
  - lib/studio/oauth_identity.rb
581
582
  - lib/studio/profile_image.rb