studio-engine 0.92.0 → 0.92.2

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: 823f5e3df6cb271556188c72d4322a968b5cf63f8635c37b70fc71a56ad93fe3
4
- data.tar.gz: ab908a4f8a0c9beae55f1844bbc2957b43c7bc33f1643c3ae083473ae68473d7
3
+ metadata.gz: 1e0b538d638180facefc1cff85cc4c5ccbea6aa49711d6ac48f1863a87e8d776
4
+ data.tar.gz: c48c355f0e8e9e2164649383eb61c7f47eaece289dfd0007e1f4f2d849137023
5
5
  SHA512:
6
- metadata.gz: 0f66ecc8ebf3c1b6318e88e4b069cb2bc4b19452db48035558eea01cb5cf163fcf126435dfdc8b74d5e837bcd65535a10b99157e9cec1fa286f2452739f420ba
7
- data.tar.gz: a3ae0c907619c2ba686bdb3a35a82883bd1a15e010191969e52c102414e37b57982a19de4cba20ccf20866638d6814983ad06e6fe0f7027ad8bad925caba6c06
6
+ metadata.gz: 9ac3c8a86695295f537aaa8355bfdc393a3b6a7ff5409308b7fe6ab402021e145089adc5a212d44aaced6921904563ccb16cf838ec5c46a33f7c9850196e2dda
7
+ data.tar.gz: 6d0fbbc3746e31d7dfd570185740ce394f91fb5503e5c3ce6f0ad7d59c4a6ba4f302669d8fd96e4bd1e6c2ef509aa5703951b8417381411bfb5d276cc8607e1d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.92.2 — 2026-10-07
8
+
9
+ ## 0.92.1 — 2026-10-07
10
+
11
+ ### Added
12
+
13
+ - **The engine owns `error_logs`, `theme_settings` and `image_caches`.** Three
14
+ migrations (`ensure_error_logs_table`, `ensure_theme_settings_table`,
15
+ `ensure_image_caches_table`) create each table when it is absent and, on an app
16
+ that already has it, add only the engine columns it lacks, as nullable columns;
17
+ nothing is altered or dropped and no index is added. Adopt with
18
+ `bin/rails studio_engine:install:migrations` and `bin/rails db:migrate`; the
19
+ release's lock bump does both. Hosts no longer copy these tables from
20
+ `docs/NEW_APP_SETUP.md`.
21
+ - **A boot check names a missing engine column** (`Studio::HostSchema`). It
22
+ raises `Studio::HostSchemaError` in development and test, and logs (and reports
23
+ to Sentry) in production; `Studio.host_schema_check = :raise | :log | false`
24
+ overrides. Extra host columns are never reported, and rake tasks skip it.
25
+
26
+ ### Fixed
27
+
28
+ - **A theme save no longer raises on apps without `theme_settings.slug`**
29
+ (Cyvasse, McRitchie Industries and Moms App): `ensure_theme_settings_table`
30
+ adds the column `ThemeSetting` writes on every save.
31
+
7
32
  ## 0.92.0 — 2026-10-06
8
33
 
9
34
  ### Added
@@ -0,0 +1,66 @@
1
+ # The engine owns the shape of `error_logs`: ErrorLog and the /error_logs pages
2
+ # write and read the columns below on every host.
3
+ #
4
+ # A host that predates this migration already has the table, built by its own
5
+ # migration from docs/NEW_APP_SETUP.md, and the hosts disagree on details (turf
6
+ # has NOT NULL on message, an index on created_at and no unique index on slug).
7
+ # So the migration is a baseline, not a rebuild:
8
+ #
9
+ # - no table: create it in the engine's shape, with its indexes;
10
+ # - a table: add any engine column it lacks, as a plain nullable column, and
11
+ # touch nothing else. No column is changed or dropped, no constraint is
12
+ # tightened and no index is added, because a host's indexes and NOT NULLs
13
+ # are its own, and a unique index built during a release-phase migrate can
14
+ # fail on existing rows or lock a large table.
15
+ #
16
+ # On every consumer that existed when this shipped, the second branch adds
17
+ # nothing. Studio::HostSchema checks the same columns at boot.
18
+ class EnsureErrorLogsTable < ActiveRecord::Migration[7.2]
19
+ def up
20
+ unless table_exists?(:error_logs)
21
+ create_table :error_logs do |t|
22
+ t.string :slug
23
+ t.text :message
24
+ t.text :inspect
25
+ t.text :backtrace
26
+ t.string :target_type
27
+ t.bigint :target_id
28
+ t.string :target_name
29
+ t.string :parent_type
30
+ t.bigint :parent_id
31
+ t.string :parent_name
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :error_logs, :slug, unique: true
37
+ add_index :error_logs, %i[target_type target_id]
38
+ add_index :error_logs, %i[parent_type parent_id]
39
+ return
40
+ end
41
+
42
+ add_column :error_logs, :slug, :string, if_not_exists: true
43
+ add_column :error_logs, :message, :text, if_not_exists: true
44
+ add_column :error_logs, :inspect, :text, if_not_exists: true
45
+ add_column :error_logs, :backtrace, :text, if_not_exists: true
46
+ add_column :error_logs, :target_type, :string, if_not_exists: true
47
+ add_column :error_logs, :target_id, :bigint, if_not_exists: true
48
+ add_column :error_logs, :target_name, :string, if_not_exists: true
49
+ add_column :error_logs, :parent_type, :string, if_not_exists: true
50
+ add_column :error_logs, :parent_id, :bigint, if_not_exists: true
51
+ add_column :error_logs, :parent_name, :string, if_not_exists: true
52
+ add_column :error_logs, :created_at, :datetime, if_not_exists: true
53
+ add_column :error_logs, :updated_at, :datetime, if_not_exists: true
54
+ end
55
+
56
+ # The migration records nothing about whether it created the table or found
57
+ # it, so a down cannot tell the engine's table from the host's own, and
58
+ # dropping it would destroy the host's error history. It refuses instead.
59
+ def down
60
+ return unless table_exists?(:error_logs)
61
+
62
+ raise ActiveRecord::IrreversibleMigration,
63
+ "EnsureErrorLogsTable cannot tell whether it created error_logs or found the host's own; " \
64
+ "drop the table by hand if this app truly owns none of its rows."
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ # The engine owns the shape of `theme_settings`: ThemeSetting and /admin/theme
2
+ # write the columns below on every host.
3
+ #
4
+ # `slug` is the column this migration exists for. ThemeSetting includes
5
+ # Sluggable, whose before_save writes `slug` on every save, and the setup doc's
6
+ # hand-copied migration omitted it, so a theme save raises on any host built
7
+ # from that doc (cyvasse, mcritchie-industries and moms-app when this shipped).
8
+ # The slug is "theme-<app_name>", derived on save; existing rows keep a NULL
9
+ # slug until their next save, and nothing looks a theme up by slug.
10
+ #
11
+ # A baseline, not a rebuild:
12
+ #
13
+ # - no table: create it in the engine's shape, with its unique app_name index;
14
+ # - a table: add any engine column it lacks, as a plain nullable column, and
15
+ # touch nothing else. No column is changed or dropped, no constraint is
16
+ # tightened and no index is added.
17
+ #
18
+ # Studio::HostSchema checks the same columns at boot.
19
+ class EnsureThemeSettingsTable < ActiveRecord::Migration[7.2]
20
+ def up
21
+ unless table_exists?(:theme_settings)
22
+ create_table :theme_settings do |t|
23
+ t.string :app_name, null: false
24
+ t.string :slug
25
+ t.string :primary
26
+ t.string :dark
27
+ t.string :light
28
+ t.string :accent1
29
+ t.string :accent2
30
+ t.string :warning
31
+ t.string :danger
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :theme_settings, :app_name, unique: true
37
+ return
38
+ end
39
+
40
+ add_column :theme_settings, :app_name, :string, if_not_exists: true
41
+ add_column :theme_settings, :slug, :string, if_not_exists: true
42
+ add_column :theme_settings, :primary, :string, if_not_exists: true
43
+ add_column :theme_settings, :dark, :string, if_not_exists: true
44
+ add_column :theme_settings, :light, :string, if_not_exists: true
45
+ add_column :theme_settings, :accent1, :string, if_not_exists: true
46
+ add_column :theme_settings, :accent2, :string, if_not_exists: true
47
+ add_column :theme_settings, :warning, :string, if_not_exists: true
48
+ add_column :theme_settings, :danger, :string, if_not_exists: true
49
+ add_column :theme_settings, :created_at, :datetime, if_not_exists: true
50
+ add_column :theme_settings, :updated_at, :datetime, if_not_exists: true
51
+ end
52
+
53
+ # The migration records nothing about whether it created the table or found
54
+ # it, so a down cannot tell the engine's table from the host's own theme. It
55
+ # refuses instead.
56
+ def down
57
+ return unless table_exists?(:theme_settings)
58
+
59
+ raise ActiveRecord::IrreversibleMigration,
60
+ "EnsureThemeSettingsTable cannot tell whether it created theme_settings or found the host's own; " \
61
+ "drop the table or the slug column by hand if this app truly owns neither."
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # The engine owns the shape of `image_caches`: ImageCache, Studio::ImageCache
2
+ # and Studio::EmailCatalog write the columns below on every host.
3
+ #
4
+ # Hosts that cache images already have the table (from their own migration);
5
+ # hosts that never did (cyvasse and moms-app when this shipped) get it here, so
6
+ # the email banner and logo uploads work on every app.
7
+ #
8
+ # A baseline, not a rebuild:
9
+ #
10
+ # - no table: create it in the engine's shape, with the owner nullable (an
11
+ # app-global image has no owner) and the indexes that back ImageCache's
12
+ # uniqueness validations;
13
+ # - a table: add any engine column it lacks, as a plain nullable column, and
14
+ # touch nothing else. No column is changed or dropped, no constraint is
15
+ # tightened and no index is added.
16
+ #
17
+ # Studio::HostSchema checks the same columns at boot.
18
+ class EnsureImageCachesTable < ActiveRecord::Migration[7.2]
19
+ def up
20
+ unless table_exists?(:image_caches)
21
+ create_table :image_caches do |t|
22
+ t.string :owner_type
23
+ t.bigint :owner_id
24
+ t.string :purpose, null: false
25
+ t.string :variant, null: false
26
+ t.string :s3_key, null: false
27
+ t.string :source_url
28
+ t.string :content_type
29
+ t.integer :bytes
30
+
31
+ t.timestamps
32
+ end
33
+
34
+ add_index :image_caches, %i[owner_type owner_id], name: "index_image_caches_on_owner"
35
+ add_index :image_caches, :s3_key, unique: true
36
+ add_index :image_caches, %i[owner_type owner_id purpose variant], unique: true,
37
+ name: "idx_image_caches_owner_purpose_variant"
38
+ return
39
+ end
40
+
41
+ add_column :image_caches, :owner_type, :string, if_not_exists: true
42
+ add_column :image_caches, :owner_id, :bigint, if_not_exists: true
43
+ add_column :image_caches, :purpose, :string, if_not_exists: true
44
+ add_column :image_caches, :variant, :string, if_not_exists: true
45
+ add_column :image_caches, :s3_key, :string, if_not_exists: true
46
+ add_column :image_caches, :source_url, :string, if_not_exists: true
47
+ add_column :image_caches, :content_type, :string, if_not_exists: true
48
+ add_column :image_caches, :bytes, :integer, if_not_exists: true
49
+ add_column :image_caches, :created_at, :datetime, if_not_exists: true
50
+ add_column :image_caches, :updated_at, :datetime, if_not_exists: true
51
+ end
52
+
53
+ # The migration records nothing about whether it created the table or found
54
+ # it, so a down cannot tell the engine's table from the host's own cache. It
55
+ # refuses instead.
56
+ def down
57
+ return unless table_exists?(:image_caches)
58
+
59
+ raise ActiveRecord::IrreversibleMigration,
60
+ "EnsureImageCachesTable cannot tell whether it created image_caches or found the host's own; " \
61
+ "drop the table by hand if this app truly owns none of its rows."
62
+ end
63
+ end
data/lib/studio/engine.rb CHANGED
@@ -298,6 +298,13 @@ module Studio
298
298
  ::User.ancestors.include?(::ActiveRecord::Base)
299
299
  Studio.validate_user_contract!(::User)
300
300
  end
301
+
302
+ # The host's error_logs, theme_settings and image_caches carry every
303
+ # column the engine's models write (Studio::HostSchema). Skipped in a
304
+ # rake task, which boots to migrate rather than to serve.
305
+ if Studio.active_record? && !Studio::HostSchema.inside_rake_task?
306
+ Studio::HostSchema.check!
307
+ end
301
308
  end
302
309
 
303
310
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The columns the engine's models write on tables the HOST holds: error_logs,
5
+ # theme_settings and image_caches. The engine's Ensure*Table migrations create
6
+ # or complete those tables; this checks, at boot, that the host has actually
7
+ # run them, so a missing column is named at startup instead of surfacing later
8
+ # as a 500 on the first save that writes it (a theme save on a host without
9
+ # theme_settings.slug was the case that prompted it).
10
+ #
11
+ # The check is one-directional: it looks only for REQUIRED columns that are
12
+ # absent. Extra columns, extra indexes, a different NOT NULL or a different
13
+ # type are the host's business and never reported.
14
+ #
15
+ # HOW LOUD, per environment (Studio.host_schema_check):
16
+ #
17
+ # :raise development and test, by default. A developer or a CI suite sees
18
+ # Studio::HostSchemaError at boot with the table, the column and the
19
+ # two commands that fix it.
20
+ # :log every other environment (production, and QA, which runs as
21
+ # production), by default. Rails.logger.error plus a Sentry message
22
+ # when the host loads sentry-ruby. It never raises there, for two
23
+ # reasons. Heroku's release phase boots the app to run db:migrate,
24
+ # the very command that adds the column, so a raise at boot would
25
+ # make the fix undeployable. And a missing column breaks the one
26
+ # surface that writes it (an admin theme save, an email banner
27
+ # upload); taking every page down to report it trades a small
28
+ # outage for a total one.
29
+ # false off.
30
+ #
31
+ # The check never runs inside a rake task (db:migrate, install:migrations,
32
+ # assets:precompile): those boot the app precisely to change or ignore the
33
+ # schema, and a report there would be wrong by the time the task finishes. It
34
+ # also stays quiet when the database cannot be reached; it has nothing to say
35
+ # about a schema it cannot read.
36
+ module HostSchema
37
+ REQUIRED_COLUMNS = {
38
+ "error_logs" => %w[
39
+ slug message inspect backtrace
40
+ target_type target_id target_name
41
+ parent_type parent_id parent_name
42
+ created_at updated_at
43
+ ].freeze,
44
+ "theme_settings" => %w[
45
+ app_name slug primary dark light accent1 accent2 warning danger
46
+ created_at updated_at
47
+ ].freeze,
48
+ "image_caches" => %w[
49
+ owner_type owner_id purpose variant s3_key source_url content_type bytes
50
+ created_at updated_at
51
+ ].freeze
52
+ }.freeze
53
+
54
+ MODES = [:raise, :log, false].freeze
55
+
56
+ module_function
57
+
58
+ # { "table" => ["missing", "columns"] } for every required table that is
59
+ # absent (all its columns listed) or short of a column. Empty when the host
60
+ # satisfies the contract.
61
+ def missing_columns(connection)
62
+ REQUIRED_COLUMNS.each_with_object({}) do |(table, required), missing|
63
+ present = connection.table_exists?(table) ? connection.columns(table).map(&:name) : []
64
+ absent = required - present
65
+ missing[table] = absent if absent.any?
66
+ end
67
+ end
68
+
69
+ def message(missing)
70
+ lines = missing.map do |table, columns|
71
+ if columns == REQUIRED_COLUMNS[table]
72
+ " #{table}: the table is missing"
73
+ else
74
+ " #{table}: missing #{columns.join(', ')}"
75
+ end
76
+ end
77
+
78
+ <<~MSG
79
+ studio-engine: this app's database is missing columns the engine's models write.
80
+
81
+ #{lines.join("\n")}
82
+
83
+ Install and run the engine's migrations (they create a missing table, add
84
+ a missing column and change nothing else):
85
+
86
+ bin/rails studio_engine:install:migrations
87
+ bin/rails db:migrate
88
+
89
+ Set Studio.host_schema_check = false in config/initializers/studio.rb to
90
+ silence this check.
91
+ MSG
92
+ end
93
+
94
+ # The mode in force: the host's Studio.host_schema_check, or :raise in
95
+ # development and test and :log everywhere else.
96
+ def mode(setting: Studio.host_schema_check, env: Rails.env)
97
+ return setting unless setting.nil?
98
+
99
+ env.development? || env.test? ? :raise : :log
100
+ end
101
+
102
+ # The boot hook. Returns the missing-columns hash it found (empty when the
103
+ # host is whole or the check was skipped) and acts on it per `mode`.
104
+ def check!(connection: nil, mode: self.mode, logger: Rails.logger)
105
+ return {} if mode == false
106
+
107
+ missing =
108
+ begin
109
+ if connection
110
+ missing_columns(connection)
111
+ else
112
+ ActiveRecord::Base.connection_pool.with_connection { |conn| missing_columns(conn) }
113
+ end
114
+ rescue ActiveRecord::ActiveRecordError => e
115
+ logger&.debug("[studio-engine] host schema check skipped: #{e.class}: #{e.message}")
116
+ return {}
117
+ end
118
+ return missing if missing.empty?
119
+
120
+ text = message(missing)
121
+ raise Studio::HostSchemaError, text if mode == :raise
122
+
123
+ logger&.error("[studio-engine] #{text}")
124
+ report_to_sentry(text)
125
+ missing
126
+ end
127
+
128
+ # True while a rake task is running: the app booted to migrate, install or
129
+ # precompile, not to serve.
130
+ def inside_rake_task?
131
+ defined?(::Rake) && ::Rake.respond_to?(:application) &&
132
+ ::Rake.application.respond_to?(:top_level_tasks) &&
133
+ ::Rake.application.top_level_tasks.any?
134
+ end
135
+
136
+ def report_to_sentry(text)
137
+ return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
138
+
139
+ ::Sentry.capture_message(text, level: :error)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ end
144
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.92.0"
2
+ VERSION = "0.92.2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -42,6 +42,7 @@ require "studio/public_user"
42
42
  require "studio/name_parts"
43
43
  require "studio/s3"
44
44
  require "studio/image_cache"
45
+ require "studio/host_schema"
45
46
  require "studio/link_token"
46
47
  require "studio/link_resolution"
47
48
  require "studio/session_fingerprint"
@@ -1060,6 +1061,11 @@ module Studio
1060
1061
  # that intentionally break the contract).
1061
1062
  mattr_accessor :validate_user_contract, default: true
1062
1063
 
1064
+ # How loudly the boot check reports a host database missing a column the
1065
+ # engine's models write (Studio::HostSchema): :raise, :log, or false for off.
1066
+ # nil, the default, means :raise in development and test and :log elsewhere.
1067
+ mattr_accessor :host_schema_check, default: nil
1068
+
1063
1069
  # Does the host load ActiveRecord at all? True for every app that requires
1064
1070
  # `rails/all` or `active_record/railtie` (all the engine's database-backed
1065
1071
  # consumers); false for a FOOTER-ONLY consumer, an app with no database that
@@ -1091,6 +1097,7 @@ module Studio
1091
1097
  PASSWORD_USER_INSTANCE_METHODS = %i[authenticate].freeze
1092
1098
 
1093
1099
  class UserContractError < StandardError; end
1100
+ class HostSchemaError < StandardError; end
1094
1101
 
1095
1102
  def self.configure
1096
1103
  yield self
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.92.0
4
+ version: 0.92.2
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-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -640,6 +640,9 @@ files:
640
640
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
641
641
  - db/migrate/20260930120000_create_studio_site_identities.rb
642
642
  - db/migrate/20261005120000_create_studio_survey_responses.rb
643
+ - db/migrate/20261006120001_ensure_error_logs_table.rb
644
+ - db/migrate/20261006120002_ensure_theme_settings_table.rb
645
+ - db/migrate/20261006120003_ensure_image_caches_table.rb
643
646
  - docs/BOOKING.md
644
647
  - docs/SITE_FOOTER.md
645
648
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -658,6 +661,7 @@ files:
658
661
  - lib/studio/geo.rb
659
662
  - lib/studio/geo/countries.rb
660
663
  - lib/studio/geo/lookup.rb
664
+ - lib/studio/host_schema.rb
661
665
  - lib/studio/image_cache.rb
662
666
  - lib/studio/ip_locations.rb
663
667
  - lib/studio/js_identifier.rb