Class: LcpRuby::Engine

Inherits:
Rails::Engine
  • Object
show all
Defined in:
lib/lcp_ruby/engine.rb

Constant Summary collapse

BIND_TO_APPLICATOR_MAP =
{
  "ransack"          => ModelFactory::RansackApplicator,
  "scopes"           => ModelFactory::ScopeApplicator,
  "validations"      => ModelFactory::ValidationApplicator,
  "virtual_columns"  => ModelFactory::AggregateApplicator,
  "transforms"       => ModelFactory::TransformApplicator,
  "computed_fields"  => ModelFactory::ComputedApplicator,
  "custom_fields"    => CustomFields::Applicator,
  "workflow"         => ModelFactory::WorkflowApplicator,
  "userstamps"       => ModelFactory::UserstampsApplicator,
  "soft_delete"      => ModelFactory::SoftDeleteApplicator,
  "auditing"         => ModelFactory::AuditingApplicator,
  "tree"             => ModelFactory::TreeApplicator,
  "sequences"        => ModelFactory::SequenceApplicator,
  "positioning"      => ModelFactory::PositioningApplicator,
  "attachments"      => ModelFactory::AttachmentApplicator,
  "enums"            => ModelFactory::EnumApplicator,
  "service_accessors" => ModelFactory::ServiceAccessorApplicator,
  "json_types"       => ModelFactory::JsonTypeApplicator,
  "array_types"      => ModelFactory::ArrayTypeApplicator,
  "defaults"         => ModelFactory::DefaultApplicator,
  "inherited_parent_validator" => ModelFactory::InheritedParentValidatorApplicator
}.freeze
RELOAD_MUTEX =

Serializes ‘reload!` against itself in dev/test so a YAML edit that triggers autoreload while another thread is mid-`reset!` does not crash the in-flight request with `RegistryError`. Production deploys are expected to drain traffic before reload (rolling restart, blue-green) and never hit this; the mutex is a no-op there but costs nothing on the read path which never enters `reload!`.

Mutex.new

Class Method Summary collapse

Class Method Details

.apply_bind_to_managed!(model_def) ⇒ Object

Re-apply ‘lcp_managed:` schema (additive columns) and AR macros for a bind_to: host class. Idempotent. Used by `lcp_ruby:ensure_tables` to recover after `db:schema:load` has recreated the host table without the managed columns (those columns are LCP-runtime-added and never present in `schema.rb`).

Safe to call repeatedly: ‘SchemaManager.apply_managed!` skips existing columns; `AssociationApplicator` short-circuits on `already_managed?` for previously-installed macros.



571
572
573
574
575
576
577
578
579
# File 'lib/lcp_ruby/engine.rb', line 571

def apply_bind_to_managed!(model_def)
  return unless model_def.bind_to?

  host_class = LcpRuby.registry.model_for(model_def.name)
  return unless host_class

  apply_bind_to_managed_schema(host_class, model_def)
  apply_bind_to_managed_associations(host_class, model_def)
end

True when app/assets/config/manifest.js exists AND explicitly links the engine bundle (‘//= link lcp_ruby/application…`, what `lcp_ruby:install` writes). A host’s own ‘//= link_tree`/`link_directory` sweeps ITS asset dirs, never the gem’s bundled assets — so a manifest that only sweeps ‘../images` or `../stylesheets` does NOT serve the engine JS. Requiring the explicit link is what actually catches failure mode B (sprockets-rails present, engine bundle unlinked → Sprockets serves the raw `//= require` stub, nav silently inert). Hosts that bundle the engine some other way set skip_asset_pipeline_check (short-circuited earlier). Missing manifest →false. A read error never blocks boot: degrade to “assume linked”.

Returns:

  • (Boolean)


331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'lib/lcp_ruby/engine.rb', line 331

def self.asset_manifest_links_engine_bundle?
  return true unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root

  manifest = Rails.root.join("app/assets/config/manifest.js")
  return false unless File.exist?(manifest)

  # Match an actual `//= link … lcp_ruby/application` directive at line start,
  # not a bare substring — a commented-out `# //= link lcp_ruby/application`
  # must not count as linked.
  File.read(manifest).match?(%r{^\s*//=\s*link\b.*lcp_ruby/application})
rescue SystemCallError => e
  raise unless Rails.env.production?
  LcpRuby.record_error(e, subsystem: "asset_pipeline")
  true
end

.asset_pipeline_error_no_manifestObject



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/lcp_ruby/engine.rb', line 385

def self.asset_pipeline_error_no_manifest
  LcpRuby::AssetPipelineError.new(<<~MSG)
    LCP Ruby's JavaScript bundle is not being served. `sprockets-rails`
    is installed, but `app/assets/config/manifest.js` does not link the
    engine bundle — Sprockets then serves the raw `//= require` source
    instead of the compiled file, so the top navigation bar and every
    Stimulus controller are silently inert (no console error, no log).

    Fix (recommended):

      bin/rails generate lcp_ruby:install

    which creates/updates `app/assets/config/manifest.js` with:

      //= link lcp_ruby/application.js
      //= link lcp_ruby/application.css
      //= link lcp_ruby/tom-select.css
      //= link lcp_ruby/tom-select.complete.min.js

    Then restart. The engine bundle must be linked explicitly — a
    `//= link_tree`/`//= link_directory` sweep of your own asset dirs does
    NOT reach the gem's bundled assets.

    Opt out (only if you've wired your own ESM bundler):

      # config/initializers/lcp_ruby.rb
      LcpRuby.configure { |c| c.skip_asset_pipeline_check = true }

    Full background: docs/reference/asset-pipeline.md
  MSG
end

.asset_pipeline_error_no_sprocketsObject



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/lcp_ruby/engine.rb', line 347

def self.asset_pipeline_error_no_sprockets
  LcpRuby::AssetPipelineError.new(<<~MSG)
    LCP Ruby requires a Sprockets-compatible asset pipeline, but the host
    app appears to use Propshaft alone (Rails 8 default) without
    sprockets-rails. The engine's JavaScript (~50 files glued by
    `//= require` directives) cannot be served — the top navigation
    bar and every Stimulus controller will be silently inert.

    Fix (recommended, takes 30 seconds):

      bin/rails generate lcp_ruby:install

    which adds `gem "sprockets-rails"` to your Gemfile and creates
    `app/assets/config/manifest.js` with the right link directives.
    Then `bundle install` and restart.

    Manual fix (equivalent):

      # Gemfile
      gem "sprockets-rails"

      # app/assets/config/manifest.js
      //= link lcp_ruby/application.js
      //= link lcp_ruby/application.css
      //= link lcp_ruby/tom-select.css
      //= link lcp_ruby/tom-select.complete.min.js

    Then `bundle install` and restart.

    Opt out (only if you've wired your own ESM bundler):

      # config/initializers/lcp_ruby.rb
      LcpRuby.configure { |c| c.skip_asset_pipeline_check = true }

    Full background: docs/reference/asset-pipeline.md
  MSG
end

.check_asset_pipeline_compat!Object

Extracted as a class method so it can be unit-tested without booting Rails — same pattern as ‘register_oidc_bearer_resolver_if_enabled!`.



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/lcp_ruby/engine.rb', line 299

def self.check_asset_pipeline_compat!
  # Skip during `rails generate lcp_ruby:install` (chicken-and-egg:
  # the generator IS what adds sprockets-rails + manifest.js to the
  # host). Any other generate context is also skipped — the user is
  # likely in the middle of bootstrapping and the next `rails s` /
  # `db:prepare` will catch it.
  return if LcpRuby.generator_context?
  return if LcpRuby.configuration.skip_asset_pipeline_check
  # Failure mode A — sprockets-rails is NOT loaded: Propshaft alone can't
  # serve the engine's `//= require` bundle.
  raise asset_pipeline_error_no_sprockets unless defined?(Sprockets::Rails)

  # Failure mode B — sprockets-rails IS loaded but app/assets/config/manifest.js
  # doesn't link the engine bundle (or is missing): Sprockets serves the raw
  # `//= require` stub instead of the compiled file, so the nav is silently
  # inert. The error message already names this case — now we detect it
  # instead of returning clean.
  return if asset_manifest_links_engine_bundle?

  raise asset_pipeline_error_no_manifest
end

.disable_sprockets_dev_cache_on_windows!(app) ⇒ Object

Windows-only dev-server immunisation against Sprockets’ non-atomic cache write (from a 2026-05-29 Windows bug report). ‘Sprockets::PathUtils. atomic_write` writes the compiled-asset cache to a temp file then `File.rename`s it into place; on Windows that rename raises `Errno::EACCES` whenever the source/target handle is still held (the writer’s own un-GC’d handle, Windows Search, antivirus, OneDrive). A stock app touches one or two assets on first load and rarely trips it; LCP declares 12+ precompile entries that all hit the cache in parallel on the very first request, so the rename race fires ~95% of the time — crashing the first page of every fresh ‘rails server`. Subsequent requests read the now-existing cache and skip the write, which is why “reload always fixes it”.

Swapping the file-backed assets cache for a null_store removes the rename entirely (slightly slower dev compilation, no race). Scoped as narrowly as the bug: Windows only (Unix keeps its cache), non-production only — development and test both live-compile assets on demand and race the same way; production precompiles once at deploy and never live-writes this cache, so it’s skipped — and a no-op when Sprockets isn’t loaded (a Propshaft-only host has no such cache). Hosts that want the cache back can re-set ‘env.cache` in their own `config.assets.configure`.



258
259
260
261
262
263
264
265
# File 'lib/lcp_ruby/engine.rb', line 258

def self.disable_sprockets_dev_cache_on_windows!(app)
  return unless sprockets_dev_cache_race_prone?
  return unless app.config.respond_to?(:assets)

  app.config.assets.configure do |env|
    env.cache = ActiveSupport::Cache.lookup_store(:null_store)
  end
end

.ensure_active_record_loaded!Object

Drains the ‘on_load(:active_record)` queue at the caller’s expense rather than during ‘load_metadata!`. The engine’s ‘lcp_ruby.load_metadata` initializer registers a callback there; in non-eager-load contexts (rake, `bin/rails runner`) AR::Base is loaded lazily, so the callback fires synchronously the first time `build_model` references it — re-entering `load_metadata!`. Rake tasks that call `load_metadata!` directly should call this first to make the on_load fire deterministically. The `@metadata_loading` guard inside `load_metadata!` is the in-engine backstop.



185
186
187
# File 'lib/lcp_ruby/engine.rb', line 185

def self.ensure_active_record_loaded!
  ActiveRecord::Base
end

.load_metadata!Object



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
# File 'lib/lcp_ruby/engine.rb', line 451

def load_metadata!
  return if @metadata_loaded
  # Re-entrance guard: model building below references ActiveRecord::Base,
  # which fires the `on_load(:active_record)` hook registered by the
  # `lcp_ruby.load_metadata` initializer. In dev/test rake contexts the
  # boot-time AR load is deferred, so that hook fires for the first time
  # *during* this method — re-entering it. Without this flag the second
  # entry would run `loader.load_all` on the already-populated, memoized
  # Loader and raise "Duplicate presenter/model ...". @metadata_loaded
  # is set true only at the end, so it cannot serve as the re-entry
  # guard on its own.
  return if @metadata_loading
  @metadata_loading = true

  # Re-register engine-internal action keys and (re-)evaluate the
  # OIDC bearer gate on every load. Both registries are cleared by
  # `LcpRuby.reset!` (called from `Engine.reload!`), so the bare
  # initializer-once registrations are gone by the time we get here
  # on a reload. Idempotent — safe at first boot too.
  register_engine_action_keys!
  register_oidc_bearer_resolver_if_enabled!

  Types::BuiltInTypes.register_all!
  Services::BuiltInTransforms.register_all!
  Services::BuiltInDefaults.register_all!
  Services::BuiltInAccessors.register_all!
  Services::Registry.discover!(Rails.root.join("app").to_s)
  Display::RendererRegistry.register_built_ins!
  Display::RendererRegistry.discover!(Rails.root.join("app").to_s)
  ViewSlots::Registry.register_built_ins!
  ConditionServiceRegistry.register_built_ins!

  loader = LcpRuby.loader
  loader.load_all

  # Build models in topological order (parents before children) for STI
  sorted_model_defs = Metadata::ModelInheritanceResolver.sort_definitions(loader.model_definitions)
  sorted_model_defs.each do |model_def|
    build_model(model_def, loader)
  end

  CustomFields::Setup.apply!(loader)
  RecordAliases::Setup.apply!(loader)
  Roles::Setup.apply!(loader)
  Permissions::Setup.apply!(loader)
  Groups::Setup.apply!(loader)
  Auditing::Setup.apply!(loader)
  Workflow::Setup.apply!(loader)
  BackgroundJobs::Setup.apply!(loader)
  SavedFilters::Setup.apply!(loader)
  Export::Setup.apply!(loader)
  Import::Setup.apply!(loader)
  Pages::Setup.apply!(loader)
  loader.merge_db_pages!
  DataSource::Setup.apply!(loader)

  # Late-bound host extensions (e.g. `LcpRuby.on_models_loaded { ... }`
  # registering custom AR scopes referenced by permission YAML) fire
  # *before* the runtime invariant validator, so AUTH-001's
  # `klass.respond_to?(method)` check sees them.
  LcpRuby._drain_models_loaded_callbacks!(loader)
  Authorization::RuntimeInvariantValidator.new(loader).run!

  Metrics::Setup.apply!(loader)

  LcpRuby.check_services!
  LcpRuby.check_action_text_compat!

  @metadata_loaded = true
  LcpRuby.instance_variable_set(:@booted, true)

  ActiveSupport::Notifications.instrument("boot.lcp_ruby", instrumentation_payload(loader))
ensure
  @metadata_loading = false
end

.register_engine_action_keys!Object

Re-applies engine-internal ‘Actions::ActionRegistry.register` calls. Called from the `lcp_ruby.api_tokens` initializer at boot AND from the start of every `Engine.load_metadata!` — `LcpRuby.reset!` clears `Actions::ActionRegistry`, so the bare initializer-once registration would lose these keys on `Engine.reload!`. `register` overwrites by name, so re-firing at every load is idempotent. Engine-internal `on_model_ready` registrations live in the initializer body directly — they survive reload via `@configuration` preservation in `LcpRuby.reset!` and would accumulate if re-fired here.



169
170
171
172
173
174
# File 'lib/lcp_ruby/engine.rb', line 169

def self.register_engine_action_keys!
  LcpRuby::Actions::ActionRegistry.register(
    "api_token/revoke",
    LcpRuby::Actions::ApiTokens::Revoke
  )
end

.register_oidc_bearer_resolver_if_enabled!Object

Boot-time gate for the OIDC bearer resolver. Extracted as a class method so it can be unit-tested without booting Rails — see spec/lib/lcp_ruby/engine_oidc_bearer_spec.rb. Idempotent: re-registering by name overwrites in the registry.



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/lcp_ruby/engine.rb', line 193

def self.register_oidc_bearer_resolver_if_enabled!
  return false unless LcpRuby::Authentication::ProviderRegistry.configured?
  return false unless LcpRuby::Authentication::ProviderRegistry.oidc_enabled?
  return false if LcpRuby::Authentication::ProviderRegistry.oidc_providers.none? { |p| p.audience.to_s.length.positive? }

  LcpRuby::Authentication::OidcBearerResolver.register!
  true
rescue LcpRuby::Authentication::ConfigurationError => e
  # Symmetric to OmniAuthBuilder.install_middleware!: in production, fail
  # loud; in dev/test, log + skip so `rake lcp_ruby:validate` can run
  # and surface the problem.
  raise if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?

  Rails.logger.warn(
    "[lcp_ruby] OIDC bearer resolver NOT registered — auth.yml is invalid: #{e.message}"
  ) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
  false
end

.reload!Object



527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'lib/lcp_ruby/engine.rb', line 527

def reload!
  RELOAD_MUTEX.synchronize do
    @metadata_loaded = false
    # `reset_for_reload!` (NOT `reset!`) — preserves
    # `@configuration` and `@models_loaded_callbacks` so host
    # settings and host-registered callbacks from
    # `config/initializers/lcp_ruby.rb` survive the reload.
    # See docs/design/boot_reload_lifecycle.md § Q7.
    LcpRuby.reset_for_reload!
    begin
      load_metadata!
    rescue Authorization::InvariantError, MetadataError
      # Rollback half-loaded state so the next request re-boots
      # cleanly rather than serving from a partially-populated
      # registry. Rescue scope is intentionally narrow — host-side
      # StandardError from a custom `on_models_loaded` callback or
      # unexpected platform bugs propagate without rollback so the
      # host's restart path handles them. See
      # docs/design/authorization_hardening.md § "Reload-failure
      # rollback".
      @metadata_loaded = false
      LcpRuby.reset_for_reload!
      raise
    end

    # Reload-only signal so host apps can subscribe to "metadata
    # was reloaded" without also being notified on initial boot.
    # `boot.lcp_ruby` fires from inside `load_metadata!` and so
    # also fires here — that is the general "metadata is now
    # loaded" signal. `reload.lcp_ruby` is the more specific "and
    # it was a reload, not an initial boot" signal.
    ActiveSupport::Notifications.instrument("reload.lcp_ruby", instrumentation_payload(LcpRuby.loader))
  end
end

.sprockets_dev_cache_race_prone?Boolean

Extracted predicate so the platform/env gating is unit-testable without a Windows box — same pattern as ‘check_asset_pipeline_compat!`.

Returns:

  • (Boolean)


269
270
271
272
273
274
275
276
# File 'lib/lcp_ruby/engine.rb', line 269

def self.sprockets_dev_cache_race_prone?
  return false unless Gem.win_platform?
  return false unless defined?(Rails) && Rails.respond_to?(:env)
  return false if Rails.env.production?
  return false unless defined?(Sprockets)

  true
end