demo_mode 3.6.0 → 3.7.1

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: 5b2e916a3b2b58e6f3cbd027fad72a91b8cb550443a50e64988caa0dbd1ff6fa
4
- data.tar.gz: ff00e7655ecf6d9e5ae118b84542bf97ec6e568143e4c2961ef42b1c28e0894c
3
+ metadata.gz: 268e07b92704ae904b434f1c2865a8a21ece4adfc51775339aa2f5243a7e4624
4
+ data.tar.gz: fe8974ba779ad030bf120811158e19f767c736c3c4faecf1c59a40dd019c7298
5
5
  SHA512:
6
- metadata.gz: b240952b7f353fff7cb723a409cc07806ffc477d83640968d486d4050f69d37d51777fa93e603f3e41b869226314cffba020fc7df96f640220dd5389591c0895
7
- data.tar.gz: ee5ebfd9364b612abaa6dbff7b0f6d6f0e3624dbd7383486282c9504e85d4d96ee54c39c76b143f4d4f10cb06f557ee0355595b57ca35faf5384ca951f6d883f
6
+ metadata.gz: f0da8528e1c63ea16b3ca1f9c1e3a6fb1731ea4e5e406bb93e5512422e311139a4e27c1fe6fc38ec45dc9b9be10223625ad15455b73765025d31480a4b8f0fe4
7
+ data.tar.gz: 75382c2d121e8308a9155280320ce94e3a7a04e0021161c3145a78ec6b6f8ec31815c15562197031cc55a757791cde01be774d4bc1a8a2312853dd6efa8d1bad
data/README.md CHANGED
@@ -48,6 +48,8 @@ To learn more about how we use `demo_mode` at **Betterment**, check out :sparkle
48
48
  - [Non-User Personas](#non-user-personas)
49
49
  - [FactoryBot `sequence` extension](#factorybot-sequence-extension)
50
50
  - [Database-backed sequences](#database-backed-sequences)
51
+ - [Persona Pooling](#persona-pooling)
52
+ - [Disabling Personas or Variants](#disabling-personas-or-variants)
51
53
  - [Deploying a demo environment to the cloud](#deploying-a-demo-environment-to-the-cloud)
52
54
  - [How to avoid breaking your new "demo" env](#how-to-avoid-breaking-your-new-demo-env)
53
55
  - [How to Contribute](#how-to-contribute)
@@ -571,6 +573,66 @@ You can check this setting with:
571
573
  CleverSequence.enforce_sequences_exist? # => false (default)
572
574
  ```
573
575
 
576
+ ### Persona Pooling
577
+
578
+ By default, Demo Mode generates persona accounts on-demand when a user clicks the persona picker. This means each click triggers a background job, and the user waits on a loading spinner. With persona pooling, accounts are pre-generated in the background so that sign-in is near-instant.
579
+
580
+ To enable pooling, schedule `DemoMode::PoolHydrationJob` to run periodically — every few minutes is a good starting point:
581
+
582
+ ```ruby
583
+ # Enqueue via your scheduler (e.g. Sidekiq-Cron, GoodJob, solid_queue, etc.)
584
+ DemoMode::PoolHydrationJob.perform_later
585
+ ```
586
+
587
+ When called without arguments, the job runs in "orchestrator" mode: it checks the current pool depth for every persona+variant combination and enqueues individual hydration jobs for any that fall below the target size. Those leaf jobs each create one pre-generated session, then re-enqueue themselves until the target is reached.
588
+
589
+ ```ruby
590
+ DemoMode.configure do
591
+ minimum_pool_size 10
592
+ end
593
+ ```
594
+
595
+ When a user selects a persona, Demo Mode atomically claims a pre-generated session from the pool ("pool hit") or falls back to on-demand generation if the pool is empty.
596
+
597
+ **Automatic invalidation:** Pool sessions are tied to a checksum of the persona file. If you change a persona's definition, stale sessions are automatically skipped and fresh ones are generated on the next hydration run — no manual cleanup required.
598
+
599
+ **Monitoring:** Demo Mode emits `ActiveSupport::Notifications` events you can subscribe to:
600
+
601
+ | Event | Emitted when | Notable payload |
602
+ |---|---|---|
603
+ | `demo_mode.pool.depth` | Each orchestration run, per persona+variant | `persona_name`, `variant`, `value` (sessions needed to reach target) |
604
+ | `demo_mode.session.claimed` | Each sign-in | `persona_name`, `variant`, `pool_hit: true/false` |
605
+
606
+ A `pool_hit: false` on `demo_mode.session.claimed` means the pool was empty at sign-in time and generation happened on-demand — a signal to increase `minimum_pool_size` or run hydration more frequently.
607
+
608
+ ### Disabling Personas or Variants
609
+
610
+ You can conditionally disable a persona or a variant by providing an `enabled` block. When the block returns false, the persona (or variant) is hidden from the picker UI, excluded from pool hydration, and treated as non-existent for session creation.
611
+
612
+ **Persona-level:**
613
+
614
+ ```ruby
615
+ DemoMode.add_persona 'Beta Feature' do
616
+ enabled { FeatureFlags.beta_enabled? }
617
+ features << 'Access to beta'
618
+ sign_in_as { FactoryBot.create(:user, :beta) }
619
+ end
620
+ ```
621
+
622
+ **Variant-level:**
623
+
624
+ ```ruby
625
+ DemoMode.add_persona 'Investor' do
626
+ variant('default') { sign_in_as { FactoryBot.create(:investor) } }
627
+ variant('accredited') do
628
+ enabled { ENV['ACCREDITED_ENABLED'].present? }
629
+ sign_in_as { FactoryBot.create(:investor, :accredited) }
630
+ end
631
+ end
632
+ ```
633
+
634
+ Common use cases include feature-flag-gated personas, environment-specific personas (e.g. only in staging), and temporarily hiding a persona without deleting its definition.
635
+
574
636
  ## Deploying a demo environment to the cloud
575
637
 
576
638
  This gem truly shines when used to deploy a "demo" version of
@@ -24,8 +24,7 @@ module DemoMode
24
24
  end
25
25
 
26
26
  def create
27
- @session = Session.new(create_params)
28
- @session.save_and_generate_account_later!(**options_params.to_unsafe_h.deep_symbolize_keys)
27
+ @session = Session.claim_for(**create_params.to_h.symbolize_keys, **options_params.to_h.deep_symbolize_keys)
29
28
  @session.reload
30
29
  session[:demo_session] = { 'id' => @session.id, 'last_request_at' => Time.zone.now }
31
30
  respond_to do |f|
@@ -51,12 +50,12 @@ module DemoMode
51
50
 
52
51
  def render_signinable_json
53
52
  if @session.signinable.blank?
54
- render json: { id: @session.id, processing: @session.processing?, status: @session.status }
53
+ render json: { id: @session.id, processing: @session.processing?, status: @session.status.to_s }
55
54
  else
56
55
  render json: {
57
56
  id: @session.id,
58
57
  processing: @session.processing?,
59
- status: @session.status,
58
+ status: @session.status.to_s,
60
59
  username: @session.signinable_username,
61
60
  password: @session.signinable_password,
62
61
  metadata: @session.signinable_metadata,
@@ -8,7 +8,8 @@ module DemoMode
8
8
  raise "Unknown persona: #{session.persona_name}" if persona.blank?
9
9
 
10
10
  signinable = persona.generate!(variant: session.variant, password: session.signinable_password, options: options)
11
- session.update!(signinable: signinable, status: 'successful')
11
+ new_status = session.claimed_at? ? 'in_use' : 'available'
12
+ session.update!(signinable: signinable, status: new_status, persona_checksum: persona.file_checksum)
12
13
  end
13
14
  rescue StandardError => e
14
15
  session.update!(status: 'failed')
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DemoMode
4
+ class PoolHydrationJob < DemoMode.base_job_name.constantize
5
+ def perform(persona_name: nil, variant: nil, count: nil)
6
+ if persona_name && variant
7
+ hydrate(persona_name, variant, count)
8
+ else
9
+ orchestrate(count)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def orchestrate(count)
16
+ target = count || DemoMode.minimum_pool_size
17
+
18
+ DemoMode.personas.each do |persona|
19
+ persona.variants.each_key do |v|
20
+ available = DemoMode::Session.available_for(persona.name, v).count
21
+ ActiveSupport::Notifications.instrument('demo_mode.pool.depth',
22
+ persona_name: persona.name, variant: v, value: target - available)
23
+ next if available >= target
24
+
25
+ PoolHydrationJob.perform_later(persona_name: persona.name, variant: v, count: count)
26
+ end
27
+ end
28
+ end
29
+
30
+ def hydrate(persona_name, variant, count)
31
+ return unless DemoMode.personas.any? { |p| p.name.to_s == persona_name.to_s && p.variants.key?(variant) }
32
+
33
+ target = count || DemoMode.minimum_pool_size
34
+ return if DemoMode::Session.available_for(persona_name, variant).count >= target
35
+
36
+ DemoMode::Session.new(persona_name: persona_name, variant: variant, pool_session: true)
37
+ .save_and_generate_account!
38
+
39
+ if DemoMode::Session.available_for(persona_name, variant).count < target
40
+ PoolHydrationJob.perform_later(persona_name: persona_name, variant: variant, count: count)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,17 +2,38 @@
2
2
 
3
3
  module DemoMode
4
4
  class Session < ActiveRecord::Base
5
- attribute :variant, default: :default
5
+ include ::SteadyState
6
6
 
7
- enum :status, { processing: 'processing', successful: 'successful', failed: 'failed' }, default: 'processing'
7
+ DEFAULT_VARIANT = 'default'
8
+
9
+ attribute :variant, default: DEFAULT_VARIANT
10
+
11
+ attr_accessor :pool_session
12
+
13
+ steady_state :status do
14
+ state 'processing', default: true
15
+ state 'available', from: 'processing'
16
+ state 'in_use', from: %w(processing available)
17
+ state 'failed', from: 'processing'
18
+ end
19
+
20
+ scope :unclaimed, -> { where(claimed_at: nil) }
21
+ scope :claimed, -> { where.not(claimed_at: nil) }
22
+ scope :available_for, ->(persona_name, variant) {
23
+ persona = DemoMode.personas.find { |p| p.name.to_s == persona_name.to_s }
24
+ available.unclaimed.where(persona_name: persona_name, variant: variant, persona_checksum: persona&.file_checksum)
25
+ }
8
26
 
9
27
  validates :persona_name, :variant, presence: true
10
28
  validates :persona, presence: { message: :required }, on: :create, if: :persona_name?
11
- validate :successful_status_requires_signinable
29
+ validates :claimed_at, absence: true, if: :available?
30
+ validates :claimed_at, presence: true, if: :in_use?
31
+ validate :terminal_status_requires_signinable
12
32
 
13
33
  belongs_to :signinable, polymorphic: true, optional: true
14
34
 
15
35
  before_create :set_password!
36
+ before_create :claim_if_not_pooled!
16
37
 
17
38
  delegate :begin_demo,
18
39
  :custom_sign_in?,
@@ -26,12 +47,36 @@ module DemoMode
26
47
  end
27
48
 
28
49
  def signinable_metadata
29
- successful? ? metadata.call(self) : {}
50
+ available? || in_use? ? metadata.call(self) : {}
30
51
  end
31
52
 
32
53
  # Heads up: finding a persona is not guaranteed (e.g. past sessions)
33
54
  def persona
34
- DemoMode.personas.find { |p| p.name.to_s == persona_name.to_s }
55
+ DemoMode.personas.find { |p| p.name.to_s == persona_name.to_s && p.variants.key?(variant) }
56
+ end
57
+
58
+ def self.claim_for(persona_name:, variant: DEFAULT_VARIANT, **generation_opts)
59
+ pool_hit = false
60
+ session = transaction do
61
+ existing = available_for(persona_name, variant).lock.first
62
+ pool_hit = existing.present?
63
+ (existing || new(persona_name: persona_name, variant: variant)).tap do |s|
64
+ s.claim!
65
+ AccountGenerationJob.perform_later(s, **generation_opts) if s.signinable.blank?
66
+ end
67
+ end
68
+ ActiveSupport::Notifications.instrument('demo_mode.session.claimed',
69
+ persona_name: persona_name, variant: variant, pool_hit: pool_hit)
70
+ session
71
+ end
72
+
73
+ def claim!
74
+ if new_record?
75
+ self.claimed_at = Time.zone.now
76
+ save!
77
+ else
78
+ lock!.update!(claimed_at: Time.zone.now, status: 'in_use')
79
+ end
35
80
  end
36
81
 
37
82
  def save_and_generate_account!(**options)
@@ -54,9 +99,13 @@ module DemoMode
54
99
  self.signinable_password ||= DemoMode.current_password
55
100
  end
56
101
 
57
- def successful_status_requires_signinable
58
- if status == 'successful' && signinable.blank?
59
- errors.add(:status, 'cannot be successful if signinable is not present')
102
+ def claim_if_not_pooled!
103
+ self.claimed_at ||= Time.zone.now unless pool_session
104
+ end
105
+
106
+ def terminal_status_requires_signinable
107
+ if (available? || in_use?) && signinable.blank?
108
+ errors.add(:status, 'cannot be available or in_use if signinable is not present')
60
109
  end
61
110
  end
62
111
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddClaimedAtToDemoModeSessions < ActiveRecord::Migration[5.1]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ add_column :demo_mode_sessions, :claimed_at, :datetime
8
+
9
+ reversible do |dir|
10
+ dir.up { safety_assured { execute "UPDATE demo_mode_sessions SET claimed_at = created_at" } }
11
+ end
12
+
13
+ safety_assured do
14
+ add_index :demo_mode_sessions,
15
+ %i(persona_name variant status claimed_at),
16
+ name: :index_demo_mode_sessions_on_pool_lookup,
17
+ algorithm: :concurrently
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RenameSuccessfulToInUseInDemoModeSessions < ActiveRecord::Migration[5.1]
4
+ def up
5
+ safety_assured { execute "UPDATE demo_mode_sessions SET status = 'in_use' WHERE status = 'successful'" }
6
+ end
7
+
8
+ def down
9
+ safety_assured { execute "UPDATE demo_mode_sessions SET status = 'successful' WHERE status = 'in_use'" }
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddPersonaChecksumToDemoModeSessions < ActiveRecord::Migration[5.1]
4
+ disable_ddl_transaction!
5
+
6
+ def change
7
+ add_column :demo_mode_sessions, :persona_checksum, :string
8
+ end
9
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
3
4
  require_relative 'concerns/configurable'
4
5
 
5
6
  module DemoMode
@@ -14,6 +15,7 @@ module DemoMode
14
15
  configurable_value(:personas_path) { 'config/personas' }
15
16
  configurable_value(:session_timeout) { 30.minutes }
16
17
  configurable_value(:log_level) { :debug }
18
+ configurable_value(:minimum_pool_size) { 5 }
17
19
  configurable_boolean(:display_credentials)
18
20
  configurations << :stylesheets
19
21
  configurations << :logo
@@ -21,7 +23,6 @@ module DemoMode
21
23
  configurations << :icon
22
24
  configurations << :password
23
25
  configurations << :around_persona_generation
24
- configurations << :personas
25
26
  configurations << :sign_up_path
26
27
  configurations << :sign_in_path
27
28
 
@@ -120,13 +121,16 @@ module DemoMode
120
121
  private
121
122
 
122
123
  def auto_load_personas!
123
- Rails.root.glob("#{personas_path}/**/*.rb").sort.each do |persona|
124
- raise <<~ERROR if File.readlines(persona).grep(/DemoMode\.add_persona/).empty?
125
- This file does not define a persona: #{persona}\n
124
+ Rails.root.glob("#{personas_path}/**/*.rb").sort.each do |persona_file|
125
+ raise <<~ERROR if File.readlines(persona_file).grep(/DemoMode\.add_persona/).empty?
126
+ This file does not define a persona: #{persona_file}\n
126
127
  Please use `DemoMode.add_persona`
127
128
  ERROR
128
129
 
129
- load(persona)
130
+ checksum = Digest::SHA256.hexdigest(File.read(persona_file))
131
+ before_count = @personas.length
132
+ load(persona_file)
133
+ @personas[before_count..].each { |p| p.file_checksum = checksum }
130
134
  end
131
135
  end
132
136
  end
@@ -6,7 +6,7 @@ module DemoMode
6
6
  class Persona
7
7
  include ActiveModel::Model
8
8
 
9
- attr_accessor :name
9
+ attr_accessor :name, :file_checksum
10
10
 
11
11
  validates :name, presence: true
12
12
  validate :persona_must_have_at_least_one_feature
@@ -53,13 +53,13 @@ module DemoMode
53
53
  end
54
54
 
55
55
  def variant(name, &block)
56
- variants[name] = Variant.new(name: name).tap do |v|
56
+ (@variants ||= {}.with_indifferent_access)[name] = Variant.new(name: name).tap do |v|
57
57
  v.instance_eval(&block)
58
58
  end
59
59
  end
60
60
 
61
61
  def variants
62
- @variants ||= {}.with_indifferent_access
62
+ (@variants ||= {}.with_indifferent_access).select { |_, v| v.enabled? }
63
63
  end
64
64
 
65
65
  def generate!(variant: :default, password: nil, options: {})
@@ -89,6 +89,14 @@ module DemoMode
89
89
  true
90
90
  end
91
91
 
92
+ def enabled(&block)
93
+ @enabled_condition = block
94
+ end
95
+
96
+ def enabled?
97
+ @enabled_condition ? @enabled_condition.call : true
98
+ end
99
+
92
100
  def callout(callout = true) # rubocop:disable Style/OptionalBooleanParameter
93
101
  @callout = callout
94
102
  end
@@ -138,6 +146,14 @@ module DemoMode
138
146
  @signinable_generator = signinable_generator
139
147
  end
140
148
 
149
+ def enabled(&block)
150
+ @enabled_condition = block
151
+ end
152
+
153
+ def enabled?
154
+ @enabled_condition ? @enabled_condition.call : true
155
+ end
156
+
141
157
  def title
142
158
  name.is_a?(Symbol) ? name.to_s.titleize : name.to_s
143
159
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DemoMode
4
- VERSION = '3.6.0'
4
+ VERSION = '3.7.1'
5
5
  end
data/lib/demo_mode.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails'
4
+ require 'steady_state'
4
5
  require 'demo_mode/version'
5
6
  require 'demo_mode/clever_sequence'
6
7
  require 'demo_mode/config'
@@ -35,6 +36,10 @@ module DemoMode
35
36
  configuration.persona(name, &)
36
37
  end
37
38
 
39
+ def personas
40
+ configuration.personas.select(&:enabled?)
41
+ end
42
+
38
43
  def callout_personas
39
44
  personas.select(&:callout?)
40
45
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: demo_mode
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.6.0
4
+ version: 3.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Griffith
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-20 00:00:00.000000000 Z
11
+ date: 2026-04-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -124,6 +124,20 @@ dependencies:
124
124
  - - "<"
125
125
  - !ruby/object:Gem::Version
126
126
  version: '8.2'
127
+ - !ruby/object:Gem::Dependency
128
+ name: steady_state
129
+ requirement: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ type: :runtime
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
127
141
  - !ruby/object:Gem::Dependency
128
142
  name: actionmailer
129
143
  requirement: !ruby/object:Gem::Requirement
@@ -327,6 +341,7 @@ files:
327
341
  - app/controllers/demo_mode/application_controller.rb
328
342
  - app/controllers/demo_mode/sessions_controller.rb
329
343
  - app/jobs/demo_mode/account_generation_job.rb
344
+ - app/jobs/demo_mode/pool_hydration_job.rb
330
345
  - app/models/demo_mode/session.rb
331
346
  - app/views/demo_mode/sessions/_variant_dropdown.html.erb
332
347
  - app/views/demo_mode/sessions/new.html.erb
@@ -337,6 +352,9 @@ files:
337
352
  - db/migrate/20201111000000_add_demo_mode_sessions_variant.rb
338
353
  - db/migrate/20210505000000_add_demo_mode_sessions_password.rb
339
354
  - db/migrate/20250210222933_add_demo_mode_sessions_status.rb
355
+ - db/migrate/20260326000000_add_claimed_at_to_demo_mode_sessions.rb
356
+ - db/migrate/20260331000000_rename_successful_to_in_use_in_demo_mode_sessions.rb
357
+ - db/migrate/20260409000000_add_persona_checksum_to_demo_mode_sessions.rb
340
358
  - lib/demo_mode.rb
341
359
  - lib/demo_mode/clever_sequence.rb
342
360
  - lib/demo_mode/clever_sequence/in_memory_backend.rb