ros-apartment 4.0.0.alpha5 → 4.0.0.alpha7

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: c4e597c986618a8043acbecc8081ef0399493c545fd3720d8f57653da90025da
4
- data.tar.gz: 9fb0d04fe5abb04e93a4ef654edc14744dfc8d86d35f6d6ef8b685d7eb6ddb29
3
+ metadata.gz: '093f09466c1e39d4344764096cec982394980fb08c8b72712c96bd8ad30fdcc7'
4
+ data.tar.gz: 402f7ef009203bb95296a90d7c6ff3dd34eeebca2d70c7da86cdb398ecc6fa60
5
5
  SHA512:
6
- metadata.gz: 747e6efe4c8c073368684944bbee84cccfd0b063f389d7b4a699debd97662496db9ea220e6be6f119986fa76e3586d180bb3f61ff61c4c44bc3acc37b7784f0c
7
- data.tar.gz: 9ceef5927c8169dc2db2041fbbfba99b81c7106a9cf02814812550c1775cd2566398dd8383e78bd0d24e7a82a845d2c4418bc974c43cc5e0169cd25a6072eba5
6
+ metadata.gz: 99343f0efe17d444cc23b244581c56135a293371c97ee3fc5d2d2931e51c688c50d9d3d93d8ae55489ec36d3a6a43a910cacdc85d22c43a6c7e80a1bad189faa
7
+ data.tar.gz: 2ccd4c456cf1a5ce190d4b50dedfc131231cd1f02518c0444313987ef0a5fda8acc00c1376bfc5daac791768f1b13685d0bb47a08ae5ba755943a2cd6eb5d04b
data/README.md CHANGED
@@ -40,6 +40,25 @@ This gem is a maintained fork of the original [Apartment gem](https://github.com
40
40
  - Rails 7.2+
41
41
  - PostgreSQL 14+, MySQL 8.4+, or SQLite3
42
42
 
43
+ ### Ruby version manager
44
+
45
+ `.ruby-version` is the single source of truth for the local Ruby version (the
46
+ same file `ruby/setup-ruby` reads in CI). Both [mise](https://mise.jdx.dev) and
47
+ [rbenv](https://github.com/rbenv/rbenv) honor it — use whichever you prefer:
48
+
49
+ ```bash
50
+ # mise (recommended)
51
+ brew install mise # then add `eval "$(mise activate zsh)"` to ~/.zshrc
52
+ mise trust && mise install # one-time trust per clone, then install the pinned Ruby
53
+
54
+ # rbenv (also works, unchanged)
55
+ rbenv install "$(cat .ruby-version)"
56
+ ```
57
+
58
+ The committed `mise.toml` carries settings only (never a version): it tells mise
59
+ to honor `.ruby-version` without any per-developer global config. `mise trust`
60
+ is required once per clone; `bin/dev/setup-worktree` handles it for new worktrees.
61
+
43
62
  ### Setup
44
63
 
45
64
  ```ruby
@@ -23,10 +23,17 @@ module Apartment
23
23
  # Called by ConnectionHandling — subclasses should NOT override this.
24
24
  # base_config_override: when supplied (e.g. a role-specific config from ConnectionHandling),
25
25
  # the adapter builds the tenant config on top of it instead of its own base_config.
26
+ #
27
+ # This validates only the PHYSICAL identifier (engine rules). Raw pool-key
28
+ # safety (colon/whitespace/NUL that would corrupt "tenant:role") is enforced
29
+ # by the sole production caller, ConnectionHandling#connection_pool, before
30
+ # it builds the pool key — and independently by #create. A future caller
31
+ # that invokes this directly, bypassing connection_pool, must validate the
32
+ # raw tenant itself (TenantNameValidator.validate_common!).
26
33
  def validated_connection_config(tenant, base_config_override: nil)
27
34
  effective_base = base_config_override || base_config
28
35
  TenantNameValidator.validate!(
29
- tenant,
36
+ physical_tenant_name(tenant),
30
37
  strategy: Apartment.config.tenant_strategy,
31
38
  adapter_name: effective_base['adapter']
32
39
  )
@@ -41,9 +48,13 @@ module Apartment
41
48
  end
42
49
 
43
50
  # Create a new tenant (schema or database).
51
+ # Validates the physical identifier create_tenant actually addresses
52
+ # (raw schema name for :schema, environmentified database name otherwise),
53
+ # so create and the pool-resolution path validate the same name.
44
54
  def create(tenant)
55
+ validate_pool_key_safety!(tenant)
45
56
  TenantNameValidator.validate!(
46
- environmentify(tenant),
57
+ physical_tenant_name(tenant),
47
58
  strategy: Apartment.config.tenant_strategy,
48
59
  adapter_name: base_config['adapter']
49
60
  )
@@ -199,11 +210,34 @@ module Apartment
199
210
  end
200
211
  end
201
212
 
213
+ # The physical identifier used to address this tenant at connection time:
214
+ # the database name for database-per-tenant strategies (environmentified).
215
+ # validated_connection_config validates THIS name so the pool-resolution
216
+ # path agrees with what the connection actually targets. Schema-per-tenant
217
+ # overrides this to the raw tenant (schemas are named directly).
218
+ def physical_tenant_name(tenant)
219
+ environmentify(tenant)
220
+ end
221
+
202
222
  # Default tenant from config.
203
223
  def default_tenant
204
224
  Apartment.config.default_tenant
205
225
  end
206
226
 
227
+ private
228
+
229
+ # Validate the raw tenant for pool-key safety: it becomes "#{tenant}:#{role}"
230
+ # in the pool key, so a colon / whitespace / NUL there breaks PoolManager's
231
+ # prefix and suffix matching and could evict the wrong tenant's pool.
232
+ # Validated in addition to physical_tenant_name (which carries the engine
233
+ # rules) because a callable environmentify_strategy could transform an
234
+ # unsafe character out of the physical name while the raw name still reaches
235
+ # the pool key. to_s first so a non-String tenant is stringified, not
236
+ # rejected (it is the value the pool key interpolates anyway).
237
+ def validate_pool_key_safety!(tenant)
238
+ TenantNameValidator.validate_common!(tenant.to_s)
239
+ end
240
+
207
241
  protected
208
242
 
209
243
  def create_tenant(tenant)
@@ -38,6 +38,12 @@ module Apartment
38
38
  config.merge('schema_search_path' => search_path)
39
39
  end
40
40
 
41
+ # Schemas are named directly (never environmentified), so the physical
42
+ # identifier validated at pool-resolution time is the raw tenant name.
43
+ def physical_tenant_name(tenant)
44
+ tenant.to_s
45
+ end
46
+
41
47
  # The schema-strategy missing-tenant error: a dropped schema is not caught
42
48
  # at switch time (search_path accepts a non-existent schema silently) — it
43
49
  # surfaces on the first query as ActiveRecord::StatementInvalid
@@ -5,6 +5,13 @@ require 'thor'
5
5
  module Apartment
6
6
  class CLI < Thor
7
7
  class Migrations < Thor
8
+ # The raised Thor::Error message is the low-context surface a monitor or
9
+ # CI-log tail captures even when the stdout summary is dropped/unindexed.
10
+ # Name a few failed tenants there (capped, so a 600-tenant run does not
11
+ # produce a wall of text); the full per-tenant detail is in the summary
12
+ # and in the migrate_tenant_failed.apartment events.
13
+ FAILED_TENANTS_PREVIEW = 5
14
+
8
15
  def self.exit_on_failure? = true
9
16
 
10
17
  desc 'migrate [TENANT]', 'Run migrations for tenants'
@@ -62,7 +69,15 @@ module Apartment
62
69
  say(result.summary)
63
70
 
64
71
  trigger_schema_dump if result.success?
65
- raise(Thor::Error, "Migration failed for #{result.failed.size} tenant(s)") unless result.success?
72
+ raise(Thor::Error, migration_failure_message(result.failed)) unless result.success?
73
+ end
74
+
75
+ def migration_failure_message(failures)
76
+ names = failures.map(&:tenant)
77
+ shown = names.first(FAILED_TENANTS_PREVIEW)
78
+ more = names.size - shown.size
79
+ suffix = more.positive? ? ", and #{more} more" : ''
80
+ "Migration failed for #{names.size} tenant(s): #{shown.join(', ')}#{suffix} (see summary above)"
66
81
  end
67
82
 
68
83
  # Rollback bypasses the Migrator's parallelism and Result tracking but
@@ -29,7 +29,7 @@ module Apartment
29
29
  DESC
30
30
  method_option :force, type: :boolean, desc: 'Skip confirmation prompt'
31
31
  def drop(tenant)
32
- return say('Cancelled.') if !force? && !yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]")
32
+ return unless confirmed_destructive?(tenant)
33
33
 
34
34
  Apartment::Tenant.drop(tenant)
35
35
  say("Dropped tenant: #{tenant}") unless quiet?
@@ -77,6 +77,28 @@ module Apartment
77
77
  options[:force] || ENV['APARTMENT_FORCE'] == '1'
78
78
  end
79
79
 
80
+ # Whether the irreversible drop may proceed. --force / APARTMENT_FORCE=1
81
+ # is the explicit opt-in. On a TTY we prompt [y/N]. In a non-interactive
82
+ # context (deploy/cron/CI, binstub, rake) we cannot prompt: rather than
83
+ # let Thor's yes? read EOF as "no" — a silent cancel that still exits 0 —
84
+ # or block on $stdin.gets against an open-but-empty pipe, refuse loudly
85
+ # with a non-zero exit. Centralizing it here (not in the rake wrapper)
86
+ # covers every entry point uniformly. See issue #457.
87
+ def confirmed_destructive?(tenant)
88
+ return true if force?
89
+
90
+ unless $stdin.tty?
91
+ raise(Thor::Error,
92
+ 'apartment tenants drop is destructive and cannot prompt in a ' \
93
+ 'non-interactive context. Re-run with --force or APARTMENT_FORCE=1 to proceed.')
94
+ end
95
+
96
+ return true if yes?("Drop tenant '#{tenant}'? This cannot be undone. [y/N]")
97
+
98
+ say('Cancelled.')
99
+ false
100
+ end
101
+
80
102
  def quiet?
81
103
  options[:quiet] || ENV['APARTMENT_QUIET'] == '1'
82
104
  end
@@ -3,8 +3,8 @@
3
3
  require 'active_support/notifications'
4
4
 
5
5
  module Apartment
6
- # Thin wrapper around ActiveSupport::Notifications.
7
- # Known events: create, drop, evict (all namespaced as *.apartment).
6
+ # Thin wrapper around ActiveSupport::Notifications. All events are namespaced
7
+ # *.apartment; see docs/observability.md for the authoritative event catalog.
8
8
  module Instrumentation
9
9
  def self.instrument(event, payload = {}, &block)
10
10
  event_name = "#{event}.apartment"
@@ -6,6 +6,11 @@ require_relative 'errors'
6
6
 
7
7
  module Apartment
8
8
  class Migrator # rubocop:disable Metrics/ClassLength
9
+ # ActiveRecord exposes no public setter for advisory-lock state (only the
10
+ # advisory_locks_enabled? reader), so we toggle this private ivar directly.
11
+ # The guard in with_advisory_locks_disabled detects a future Rails rename.
12
+ ADVISORY_LOCKS_IVAR = :@advisory_locks_enabled
13
+
9
14
  Result = Data.define(
10
15
  :tenant,
11
16
  :status,
@@ -116,9 +121,15 @@ module Apartment
116
121
  duration: monotonic_now - start, error: nil, versions_run: versions
117
122
  )
118
123
  rescue StandardError => e
124
+ duration = monotonic_now - start
125
+ # Symmetric with the :migrate_tenant success event above: the gem holds the
126
+ # full structured error here, so emit it for subscribers rather than
127
+ # stranding it in the returned Result. See migrate_tenant's rescue.
128
+ instrument_failure(tenant_name, e, duration)
129
+
119
130
  Result.new(
120
131
  tenant: tenant_name, status: :failed,
121
- duration: monotonic_now - start, error: e, versions_run: []
132
+ duration: duration, error: e, versions_run: []
122
133
  )
123
134
  end
124
135
 
@@ -161,9 +172,17 @@ module Apartment
161
172
  end
162
173
  end
163
174
  rescue StandardError => e
175
+ duration = monotonic_now - start
176
+ # Failure counterpart to the :migrate_tenant success event. On SUCCESS the
177
+ # gem instruments; on FAILURE it previously only returned a failed Result,
178
+ # leaving adopters no hook to observe the (structured) error. This closes
179
+ # that asymmetry — generic payload (tenant + error + duration); routing to
180
+ # an error tracker is the subscriber's job.
181
+ instrument_failure(tenant, e, duration)
182
+
164
183
  Result.new(
165
184
  tenant: tenant, status: :failed,
166
- duration: monotonic_now - start, error: e, versions_run: []
185
+ duration: duration, error: e, versions_run: []
167
186
  )
168
187
  ensure
169
188
  Apartment::Current.migrating = false
@@ -213,13 +232,56 @@ module Apartment
213
232
  # Disable advisory locks on the leased connection for the duration of the
214
233
  # block, then restore the original value. lease_connection returns the same
215
234
  # connection object for the current thread (fiber-local via IsolatedExecutionState).
235
+ #
236
+ # PG's advisory locks are database-wide and would serialize parallel tenant
237
+ # migrations (issue #298). Rails offers no public setter, so we poke the
238
+ # private @advisory_locks_enabled ivar. The instance_variable_defined? guard
239
+ # detects a future Rails *rename or removal* of the ivar (a name-presence
240
+ # check — it does NOT catch a semantics change where Rails keeps the ivar but
241
+ # stops honoring it on the lock path). On a detected rename we warn and
242
+ # proceed rather than silently creating an orphan ivar; the ivar contract is
243
+ # also unit-tested against a real connection so a rename breaks CI first.
216
244
  def with_advisory_locks_disabled
217
245
  conn = ActiveRecord::Base.lease_connection
218
- original = conn.instance_variable_get(:@advisory_locks_enabled)
219
- conn.instance_variable_set(:@advisory_locks_enabled, false)
246
+ unless conn.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
247
+ warn "[Apartment::Migrator] ActiveRecord connection #{conn.class} does not define " \
248
+ "#{ADVISORY_LOCKS_IVAR}; cannot disable advisory locks for this Rails version. " \
249
+ 'Parallel tenant migrations may serialize or fail on the database-wide advisory lock.'
250
+ return yield
251
+ end
252
+ original = conn.instance_variable_get(ADVISORY_LOCKS_IVAR)
253
+ conn.instance_variable_set(ADVISORY_LOCKS_IVAR, false)
220
254
  yield
221
255
  ensure
222
- conn&.instance_variable_set(:@advisory_locks_enabled, original)
256
+ conn.instance_variable_set(ADVISORY_LOCKS_IVAR, original) if conn&.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
257
+ end
258
+
259
+ # Emit the failure event without letting a raising subscriber break the
260
+ # Migrator's non-raising contract: the failed Result MUST still be returned.
261
+ # ActiveSupport::Notifications propagates subscriber exceptions through
262
+ # instrument (verified against AS 8.0), and this fires from inside a rescue
263
+ # block, so an un-isolated call would convert a captured per-tenant failure
264
+ # into an unhandled raise out of #run.
265
+ #
266
+ # Scope of the guarantee: a subscriber raising a StandardError is swallowed
267
+ # and warned. Process-control exceptions (SystemExit, SignalException /
268
+ # Interrupt) are deliberately NOT rescued — they must propagate so exit and
269
+ # Ctrl-C still work mid-migration; a subscriber raising a bare Exception
270
+ # subclass is itself a bug (Ruby errors should descend from StandardError).
271
+ # The warn is nested-rescued so a broken $stderr (IOError is a StandardError)
272
+ # cannot re-escape the handler. Success-path instrumentation is left
273
+ # un-isolated by design — only the failure path carries the hard no-raise
274
+ # guarantee, and swallowing there would mask real subscriber bugs.
275
+ def instrument_failure(tenant, error, duration)
276
+ Instrumentation.instrument(:migrate_tenant_failed, tenant: tenant, error: error, duration: duration)
277
+ rescue StandardError => e
278
+ # Nested rescue: a sibling `rescue` on this method would NOT catch an
279
+ # exception raised by warn (a broken $stderr), so the warn gets its own.
280
+ begin
281
+ warn "[Apartment::Migrator] migrate_tenant_failed subscriber raised #{e.class}: #{e.message}"
282
+ rescue StandardError
283
+ nil
284
+ end
223
285
  end
224
286
 
225
287
  def monotonic_now
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_record'
4
+ require_relative '../tenant_name_validator'
4
5
 
5
6
  module Apartment
6
7
  module Patches
@@ -28,6 +29,15 @@ module Apartment
28
29
  return super
29
30
  end
30
31
 
32
+ # Reject pool-key-unsafe tenant names BEFORE building pool_key or entering
33
+ # fetch_or_create. In the capped path, fetch_or_admit runs admit! (which
34
+ # may LRU-evict an idle pool) before the adapter validates inside the
35
+ # block, so a colon / whitespace / NUL in the raw tenant — which would
36
+ # also corrupt the "#{tenant}:#{role}" key and PoolManager's prefix
37
+ # matching — must be caught here. ConfigurationError is an ApartmentError,
38
+ # so the rescue below re-raises it cleanly.
39
+ Apartment::TenantNameValidator.validate_common!(tenant.to_s)
40
+
31
41
  role = ActiveRecord::Base.current_role
32
42
  pool_key = "#{tenant}:#{role}"
33
43
 
@@ -109,7 +119,13 @@ module Apartment
109
119
  cache_path = Apartment::SchemaCache.cache_path_for(tenant)
110
120
  return unless File.exist?(cache_path)
111
121
 
112
- pool.schema_cache.load!(cache_path)
122
+ # Bind the pool's reflection to the dump file (Rails 7.1+ API). The
123
+ # removed path-taking SchemaCache#load! raised ArgumentError here:
124
+ # pool.schema_cache returns a BoundSchemaReflection whose #load! takes
125
+ # no args. SchemaReflection.new(path) lazily loads the dump (and Rails
126
+ # version-checks it, ignoring a stale file with a warning).
127
+ pool.schema_reflection =
128
+ ActiveRecord::ConnectionAdapters::SchemaReflection.new(cache_path)
113
129
  end
114
130
  end
115
131
  end
@@ -15,7 +15,16 @@ namespace :apartment do
15
15
  desc 'Drop a tenant schema/database'
16
16
  task :drop, [:tenant] => :environment do |_t, args|
17
17
  abort('Usage: rake apartment:drop[tenant_name]') unless args[:tenant]
18
- Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]], force: true)
18
+ # Plain delegate: Apartment::CLI::Tenants#drop owns the confirmation policy
19
+ # (TTY prompts [y/N]; non-interactive requires --force / APARTMENT_FORCE=1,
20
+ # otherwise raises Thor::Error). The rake wrapper adds no force handling of
21
+ # its own. It only reformats the guard error: `.new.invoke` bypasses
22
+ # Thor.start's exit_on_failure? handling, so an unrescued Thor::Error would
23
+ # surface as a raw `rake aborted!` backtrace that buries the actionable
24
+ # message — abort re-emits it as a clean one-liner with a non-zero exit.
25
+ Apartment::CLI::Tenants.new.invoke(:drop, [args[:tenant]])
26
+ rescue Thor::Error => e
27
+ abort(e.message)
19
28
  end
20
29
 
21
30
  desc 'Run migrations for all tenants (or one: rake apartment:migrate[tenant])'
@@ -25,6 +34,13 @@ namespace :apartment do
25
34
  else
26
35
  Apartment::CLI::Migrations.new.invoke(:migrate)
27
36
  end
37
+ # Parity with apartment:drop: `.new.invoke` bypasses Thor.start's
38
+ # exit_on_failure? handling, so an unrescued Thor::Error (raised on
39
+ # migration failure) would surface as a raw `rake aborted!` backtrace that
40
+ # buries the actionable message. abort re-emits it as a clean one-liner
41
+ # with a non-zero exit.
42
+ rescue Thor::Error => e
43
+ abort(e.message)
28
44
  end
29
45
 
30
46
  desc 'Seed all tenants'
@@ -293,8 +293,56 @@ module Apartment
293
293
  Apartment.pool_manager&.stats || {}
294
294
  end
295
295
 
296
+ # Clear the schema cache on warm tenant pools (and the default pool) in
297
+ # THIS PROCESS, so the next query re-reflects the database. Use after DDL
298
+ # on a pinned/shared table (which N warm tenant pools may have cached) or
299
+ # after manual DDL in a console. Lazy: clears now, repopulates from the DB
300
+ # on next access (not from any dump file).
301
+ #
302
+ # Current-process only — it cannot reach other workers' pools; fleet-wide
303
+ # DDL still needs a rolling restart. Clears schema reflection only, not
304
+ # prepared statements (AR self-heals those on PostgreSQL) and not model
305
+ # @columns_hash (call Model.reset_column_information or restart for that).
306
+ # Not a linearized barrier: an in-flight request may use metadata it
307
+ # already read. Intended for console / post-migrate / low-traffic use.
308
+ #
309
+ # tenant: nil clears all warm tenant pools + the default pool. A tenant
310
+ # name clears only that tenant's warm pools (+ the default pool when the
311
+ # name is the default tenant). Returns the count of pools cleared.
312
+ #
313
+ # Role scope: warm tenant pools are cleared across all roles (the pool key
314
+ # is "tenant:role"), but the default pool is cleared only for the CURRENT
315
+ # role. A multi-role app with a :reading default replica should call once
316
+ # per role (e.g. inside connected_to(role: :reading)) to clear the replica
317
+ # default pool too. For pinned/shared-table DDL, prefer the unscoped form
318
+ # (no tenant arg) — a tenant-scoped call clears only that tenant's pools.
319
+ def reload_schema_cache!(tenant = nil)
320
+ pools = []
321
+ Apartment.pool_manager&.each_pair do |key, pool|
322
+ pools << pool if tenant.nil? || key.start_with?("#{tenant}:")
323
+ end
324
+ default_pool = default_schema_cache_pool(tenant)
325
+ pools << default_pool if default_pool
326
+
327
+ pools.each { |pool| pool.schema_cache.clear! }
328
+ pools.size
329
+ end
330
+
296
331
  private
297
332
 
333
+ # The default pool to clear for reload_schema_cache!, or nil when it should
334
+ # be excluded (a real-tenant scope, or no default tenant configured). The
335
+ # ConnectionHandling patch routes connection_pool by Current.tenant, so we
336
+ # enter default context to get the real default pool, not a tenant one;
337
+ # guarded on `default` because with_default_tenant raises with no default.
338
+ def default_schema_cache_pool(tenant)
339
+ default = default_tenant
340
+ return nil unless default
341
+ return nil unless tenant.nil? || tenant.to_s == default.to_s
342
+
343
+ with_default_tenant { ActiveRecord::Base.connection_pool }
344
+ end
345
+
298
346
  # Raise when default_tenant_switch_allowed is false and the caller is
299
347
  # block-switching into the default tenant. switch! and reset are exempt:
300
348
  # neither enters this guard, so they remain the legitimate paths into
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha5'
4
+ VERSION = '4.0.0.alpha7'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -31,6 +31,16 @@ loader.ignore("#{__dir__}/apartment/cli")
31
31
  # as the cli.rb / cli ignores above.
32
32
  loader.ignore("#{__dir__}/rubocop")
33
33
 
34
+ # Rails generators live under lib/generators and are loaded on demand by Rails'
35
+ # generator machinery, never autoloaded. They follow Rails' generator naming
36
+ # (Apartment::InstallGenerator), not Zeitwerk's path-based inference
37
+ # (Generators::Apartment::Install::InstallGenerator), so leaving them managed
38
+ # makes `Zeitwerk::Loader.eager_load_all` — which Rails runs whenever a host app
39
+ # sets config.eager_load = true (production/CI) — raise Zeitwerk::NameError at
40
+ # boot. Ignoring the directory is the canonical for_gem pattern for gems that
41
+ # ship generators.
42
+ loader.ignore("#{__dir__}/generators")
43
+
34
44
  # Collapse concerns/ so Zeitwerk maps lib/apartment/concerns/model.rb
35
45
  # to Apartment::Model (not Apartment::Concerns::Model). Mirrors the
36
46
  # Rails convention for app/models/concerns/.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ros-apartment
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha5
4
+ version: 4.0.0.alpha7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner