igniter-embed 0.5.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b4a43a4e170a429c5aa9b8380a47b0dd9f254109eadc7268f67e205fe74d6b2
4
+ data.tar.gz: 1b40c7e366edd2704bec098aa86c463272b614f134a8c0ab3ab6c80cb994109f
5
+ SHA512:
6
+ metadata.gz: 598d577bbdeb6c7ddf50c73c90a6d24bbb8ab9414a8bab78556b0aa61754a2c3a820b627484d4de38749c965f316f106371cb1385ceac03436da2d3f2cbc47fe
7
+ data.tar.gz: 400425f586822adcc7847ad6fe0b3c4dc92ceeff28eb9250432a85eef90b882cf981e1c400e27b4f6fa342f96c95e52dee5ba63525ca28aa5c642a612218bef7
data/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # Igniter Embed
2
+
3
+ `igniter-embed` is the host-local layer for applications that want to register,
4
+ cache, and execute Igniter contracts without adopting the full application
5
+ runtime.
6
+
7
+ ```ruby
8
+ contracts = Igniter::Embed.configure(:sparkcrm) do |config|
9
+ config.cache = true
10
+ config.pack Igniter::Contracts::ProjectPack
11
+ end
12
+
13
+ contracts.register(:tax_quote) do
14
+ input :amount
15
+ compute :tax, depends_on: [:amount] do |amount:|
16
+ amount * 0.2
17
+ end
18
+ output :tax
19
+ end
20
+
21
+ result = contracts.call(:tax_quote, amount: 100)
22
+ result.success?
23
+ result.output(:tax)
24
+ ```
25
+
26
+ For human-facing app initializers, `host` is sugar over the same host-local
27
+ configuration:
28
+
29
+ ```ruby
30
+ contracts = Igniter::Embed.host(:shop) do
31
+ owner Shop
32
+ path "app/contracts"
33
+ cache !Rails.env.development?
34
+
35
+ contracts do
36
+ add :price_quote, PriceContract
37
+ end
38
+ end
39
+ ```
40
+
41
+ For app-local contract classes, prefer host-level registration:
42
+
43
+ ```ruby
44
+ class PriceContract < Igniter::Contract
45
+ define do
46
+ input :amount
47
+ compute :total, depends_on: [:amount] do |amount:|
48
+ amount * 1.2
49
+ end
50
+ output :total
51
+ end
52
+ end
53
+
54
+ contracts = Igniter::Embed.configure(:shop) do |config|
55
+ config.root "app/contracts"
56
+ config.contract PriceContract, as: :price_quote
57
+ end
58
+
59
+ contracts.call(:price_quote, amount: 100).output(:total)
60
+ ```
61
+
62
+ Named contract classes can also be registered directly:
63
+
64
+ ```ruby
65
+ contracts.register(PriceContract)
66
+ contracts.call(:price, amount: 100)
67
+ ```
68
+
69
+ `config.root` is the host-local directory where contract files live. It is
70
+ metadata for explicit registration unless discovery is enabled.
71
+
72
+ Discovery is opt-in:
73
+
74
+ ```ruby
75
+ contracts = Igniter::Embed.configure(:shop) do |config|
76
+ config.root "app/contracts"
77
+ config.discover!
78
+ end
79
+ ```
80
+
81
+ By default discovery requires `**/*_contract.rb` under `config.root` and
82
+ registers newly loaded, named `Class < Igniter::Contract` definitions by
83
+ inferred name. Anonymous contract classes are ignored by discovery and must be
84
+ registered explicitly with `as:` if you want to call them through the host.
85
+
86
+ Prefer explicit `config.contract` for application boot paths where stable
87
+ naming matters. If explicit registration and discovery produce the same name,
88
+ the explicit registration wins. If two discovered classes infer the same name,
89
+ discovery raises `Igniter::Embed::DiscoveryError` and asks you to register them
90
+ explicitly.
91
+
92
+ Rails integration is optional:
93
+
94
+ ```ruby
95
+ require "igniter/embed/rails"
96
+
97
+ Igniter::Embed::Rails.install(
98
+ contracts,
99
+ reloader: Rails.application.reloader,
100
+ cache: !Rails.env.development?
101
+ )
102
+ ```
103
+
104
+ The Rails adapter only connects host reload callbacks to `container.reload!`.
105
+ The base package remains Rails-free.
106
+
107
+ ## Contractable Shadowing
108
+
109
+ `igniter-contracts` owns the core `Contractable` service protocol used by
110
+ `compute using:`. The `igniter-embed` `contractable` API below is a host
111
+ wrapper for migration, shadowing, discovery, and production observation.
112
+
113
+ `contractable` wraps host services without changing their public API. The
114
+ primary callable runs synchronously and its raw result is returned; an optional
115
+ candidate can run through a shadow adapter, normalize outputs, compare through
116
+ `DifferentialPack`, and record an observation through an app-supplied store.
117
+ When `async` is true, the default adapter uses a local Ruby thread so candidate
118
+ work does not block the primary response. It is not a durable production job
119
+ queue; provide an app adapter for ActiveJob, Sidekiq, or another backend when
120
+ durability matters.
121
+
122
+ When a primary or candidate is a core `Igniter::Contracts::Contractable`
123
+ service, embed invokes it through the core protocol and adopts its declared
124
+ `role`, `stage`, and metadata as wrapper defaults unless the wrapper explicitly
125
+ overrides them.
126
+
127
+ ```ruby
128
+ QuoteShadow = Igniter::Embed.contractable(:quote) do |config|
129
+ config.role :migration_candidate
130
+ config.stage :shadowed
131
+ config.primary LegacyQuote
132
+ config.candidate ContractQuote
133
+ config.normalize_primary QuoteNormalizer
134
+ config.normalize_candidate QuoteNormalizer
135
+ config.accept :shape, outputs: { total: Numeric, status: String }
136
+ config.store QuoteObservationStore
137
+ end
138
+
139
+ result = QuoteShadow.call(amount: 100)
140
+ ```
141
+
142
+ The same shape can be declared through host sugar. This keeps registration,
143
+ shadow migration intent, adapters, and event hooks in one inspectable
144
+ initializer:
145
+
146
+ ```ruby
147
+ contracts = Igniter::Embed.host(:billing) do
148
+ contracts do
149
+ add :price_quote, Billing::PriceContract do
150
+ migrate Billing::LegacyQuote, to: Billing::ContractQuote
151
+ shadow async: false, sample: 1.0
152
+
153
+ use :normalizer, Billing::QuoteNormalizer
154
+ use :redaction, only: %i[amount customer_id]
155
+ use :acceptance, policy: :shape, outputs: { total: Numeric }
156
+ use :store, Billing::ObservationStore
157
+
158
+ on :divergence do |event|
159
+ Billing.logger.warn(event)
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ runner = contracts.contractable(:price_quote)
166
+ runner.call(amount: 100, customer_id: "cust_1", token: "secret")
167
+ ```
168
+
169
+ Generated contractable runners are host-local:
170
+
171
+ ```ruby
172
+ contracts.contractable_names
173
+ contracts.fetch_contractable(:price_quote)
174
+ contracts.sugar_expansion.to_h
175
+ ```
176
+
177
+ `on :failure` is an alias family for typed failure events:
178
+ `:primary_error`, `:candidate_error`, `:acceptance_failure`, and
179
+ `:store_error`. Divergence is intentionally separate and should be subscribed
180
+ to with `on :divergence`.
181
+
182
+ Capability attachment sugar exists for host-owned targets. It does not install
183
+ implicit built-ins:
184
+
185
+ ```ruby
186
+ contracts = Igniter::Embed.host(:billing) do
187
+ contracts do
188
+ add :price_quote, Billing::PriceContract do
189
+ migrate Billing::LegacyQuote, to: Billing::ContractQuote
190
+ use :normalizer, Billing::QuoteNormalizer
191
+
192
+ use :logging, contract: Billing::LogObservationContract
193
+ use :reporting, ->(event) { Billing.reporter.record(event) }
194
+ use :metrics, target: Billing::MetricsSink
195
+ use :validation, callable: Billing::ObservationValidator
196
+ end
197
+ end
198
+ end
199
+ ```
200
+
201
+ Each explicit target appears in `sugar_expansion` as either `kind: :contract`
202
+ or `kind: :callable_adapter`.
203
+
204
+ Primary-only observed services use the same surface:
205
+
206
+ ```ruby
207
+ ObservedQuote = Igniter::Embed.contractable(:quote) do |config|
208
+ config.role :observed_service
209
+ config.primary LegacyQuote
210
+ config.normalize_primary QuoteNormalizer
211
+ config.store QuoteObservationStore
212
+ end
213
+ ```
214
+
215
+ For an observed service, the normalizer should return a redacted aggregate
216
+ summary. The primary callable remains authoritative and its raw result is still
217
+ returned to the host app.
218
+
219
+ ```ruby
220
+ AvailabilityObserver = Igniter::Embed.contractable(:availability) do |config|
221
+ config.role :observed_service
222
+ config.stage :captured
223
+ config.primary AvailabilityService
224
+ config.normalize_primary AvailabilitySummaryNormalizer
225
+ config.redact_inputs ->(**inputs) { inputs.slice(:request_ref, :window_ref) }
226
+ config.store AvailabilityObservationStore
227
+ end
228
+
229
+ class AvailabilitySummaryNormalizer
230
+ def self.call(_result)
231
+ {
232
+ status: :ok,
233
+ outputs: {
234
+ status: "success",
235
+ receipt_kind: "availability_slot_map_summary",
236
+ redaction_policy: "availability_slot_map_summary_v1",
237
+ availability_bucket: "available",
238
+ dominant_unavailable_state: "day_off",
239
+ available_ratio: 0.75,
240
+ total_slots: 4,
241
+ available_slots: 3,
242
+ scheduled_slots: 0,
243
+ off_schedule_slots: 0,
244
+ day_off_slots: 1,
245
+ past_slots: 0
246
+ },
247
+ metadata: { normalizer: :availability_summary_v1 }
248
+ }
249
+ end
250
+ end
251
+ ```
252
+
253
+ The aggregate payload above is a sanitized normalizer example. It becomes part
254
+ of `receipt[:primary][:outputs]`; it is not the top-level Embed receipt
255
+ envelope. In particular, `"availability_slot_map_summary"` is fixture/example
256
+ vocabulary for the aggregate output shape, not an `igniter-embed` receipt kind.
257
+ The Embed observation receipt that contains it still uses
258
+ `receipt_kind: :contractable_observation`, and event receipts still use
259
+ `receipt_kind: :contractable_event`.
260
+
261
+ Keep this shape host-local:
262
+
263
+ - choose the observed target, rollout flag, and sample rate in the app;
264
+ - keep the redaction allow-list app-owned;
265
+ - persist receipts through an app-owned store adapter;
266
+ - treat Ledger sinks as optional adapters, not as the source of truth;
267
+ - do not infer release readiness or a public schema from synthetic aggregate
268
+ examples.
269
+
270
+ ## Observation Receipts
271
+
272
+ Each contractable call produces a canonical observation receipt. The receipt
273
+ includes a stable `observation_id`, `schema_version`, `receipt_kind`, and a
274
+ `status` that summarises the outcome:
275
+
276
+ ```text
277
+ :ok — primary and candidate matched and were accepted
278
+ :diverged — outputs diverged but acceptance policy passed
279
+ :candidate_error — candidate raised an exception
280
+ :acceptance_failed — candidate succeeded but acceptance policy failed
281
+ :store_error — store adapter raised after primary returned
282
+ :unsampled — call was outside the configured sample rate
283
+ ```
284
+
285
+ A Spark-style store adapter wires receipts into a durable sink:
286
+
287
+ ```ruby
288
+ class SparkObservationStore
289
+ def record_observation(receipt)
290
+ # receipt[:observation_id] — stable id for linking to logs/admin
291
+ # receipt[:status] — :ok | :diverged | :candidate_error | …
292
+ # receipt[:redaction] — policy applied to inputs
293
+ ObservationRecord.create!(receipt.slice(:observation_id, :status, :name, :role, :stage).merge(payload: receipt))
294
+ end
295
+
296
+ def record_event(receipt)
297
+ # receipt[:receipt_kind] == :contractable_event
298
+ # receipt[:event_id] — unique per event
299
+ # receipt[:observation_id] — links back to the observation
300
+ # receipt[:severity] — :info | :warning | :error
301
+ return unless receipt[:severity] == :error || receipt[:event] == :divergence
302
+
303
+ ObservationEvent.create!(receipt.slice(:event_id, :observation_id, :event, :severity, :summary))
304
+ end
305
+ end
306
+ ```
307
+
308
+ Register the store in a host:
309
+
310
+ ```ruby
311
+ runner = Igniter::Embed.contractable(:marketing_executor) do
312
+ migrate Api::Marketing::ExecutorService::Legacy,
313
+ to: Api::Marketing::ExecutorService::Contract
314
+ shadow async: true, sample: 0.1
315
+ use :normalizer, Api::Marketing::ExecutorNormalizer
316
+ use :redaction, only: %i[provider_payload technician_id customer_id]
317
+ use :acceptance, policy: :shape, outputs: { status: String, result: Hash }
318
+ use :store, SparkObservationStore.new
319
+
320
+ on :divergence do |event|
321
+ Rails.logger.warn("[igniter] divergence obs=#{event.dig(:receipt, :observation_id)}")
322
+ end
323
+ end
324
+ ```
325
+
326
+ A divergence event payload includes a compact receipt:
327
+
328
+ ```ruby
329
+ {
330
+ event: :divergence,
331
+ receipt: {
332
+ schema_version: 1,
333
+ receipt_kind: :contractable_event,
334
+ event_id: "evt_...",
335
+ observation_id: "obs_...",
336
+ severity: :warning,
337
+ summary: "outputs diverged from primary",
338
+ observation_ref: { observation_id: "obs_...", match: false, accepted: false }
339
+ }
340
+ }
341
+ ```
342
+
343
+ Async adapters receive a handoff descriptor for durable job wiring:
344
+
345
+ ```ruby
346
+ class SidekiqObservationAdapter
347
+ def enqueue(name:, inputs:, metadata:, handoff: nil, &block)
348
+ if handoff
349
+ ObservationJob.perform_later(
350
+ observation_id: handoff[:observation_id],
351
+ name: handoff[:name],
352
+ queued_at: handoff[:queued_at]
353
+ )
354
+ else
355
+ # fallback: run inline
356
+ block.call
357
+ end
358
+ end
359
+ end
360
+ ```
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ class Config
6
+ ContractRegistration = Struct.new(:definition, :name, keyword_init: true)
7
+
8
+ attr_reader :name, :packs, :contract_registrations,
9
+ :contractable_configs, :discovery_pattern
10
+ attr_accessor :cache, :capture_exceptions, :executor_name
11
+
12
+ UNSET = Object.new.freeze
13
+
14
+ def initialize(name:)
15
+ @name = name.to_sym
16
+ @cache = true
17
+ @root = nil
18
+ @owner = nil
19
+ @packs = []
20
+ @contract_registrations = []
21
+ @contractable_configs = []
22
+ @discovery_enabled = false
23
+ @discovery_pattern = "**/*_contract.rb"
24
+ @capture_exceptions = false
25
+ @executor_name = :inline
26
+ end
27
+
28
+ def pack(pack)
29
+ packs << pack
30
+ self
31
+ end
32
+
33
+ def owner(value = UNSET)
34
+ return @owner if value.equal?(UNSET)
35
+
36
+ @owner = value
37
+ self
38
+ end
39
+
40
+ def owner=(value)
41
+ owner(value)
42
+ end
43
+
44
+ def contract(definition, as: nil)
45
+ contract_registrations << ContractRegistration.new(definition: definition, name: as)
46
+ self
47
+ end
48
+
49
+ def contractable(config)
50
+ raise DuplicateContractError, "contractable #{config.name} is already configured" if contractable_registered?(config.name)
51
+
52
+ contractable_configs << config
53
+ self
54
+ end
55
+
56
+ def contractable_config(name)
57
+ key = name.to_sym
58
+ contractable_configs.find { |contractable_config| contractable_config.name == key }
59
+ end
60
+
61
+ def contracts(&block)
62
+ builder = ContractsBuilder.new(config: self)
63
+ evaluate_builder_block(builder, &block) if block
64
+ self
65
+ end
66
+
67
+ def root(path = nil)
68
+ return @root if path.nil?
69
+
70
+ @root = File.expand_path(path.to_s)
71
+ self
72
+ end
73
+
74
+ def root=(path)
75
+ root(path)
76
+ end
77
+
78
+ def path(value = nil)
79
+ return root if value.nil?
80
+
81
+ root(resolve_path(value))
82
+ end
83
+
84
+ def path=(value)
85
+ path(value)
86
+ end
87
+
88
+ def sugar_expansion
89
+ SugarExpansion.new(config: self)
90
+ end
91
+
92
+ def discover!(pattern: "**/*_contract.rb")
93
+ @discovery_enabled = true
94
+ @discovery_pattern = pattern
95
+ self
96
+ end
97
+
98
+ def cache?
99
+ !!cache
100
+ end
101
+
102
+ def discovery_enabled?
103
+ !!@discovery_enabled
104
+ end
105
+
106
+ def capture_exceptions?
107
+ !!capture_exceptions
108
+ end
109
+
110
+ private
111
+
112
+ def contractable_registered?(name)
113
+ contractable_configs.any? { |contractable_config| contractable_config.name == name }
114
+ end
115
+
116
+ def resolve_path(value)
117
+ if value.is_a?(Array)
118
+ raise SugarError, "path requires exactly one path in this implementation slice" unless value.length == 1
119
+
120
+ value = value.first
121
+ end
122
+
123
+ raise SugarError, "path entries must be strings or path-like objects" unless path_like?(value)
124
+
125
+ path_value = value.respond_to?(:to_path) ? value.to_path : value.to_s
126
+ return path_value if absolute_path?(path_value)
127
+
128
+ owner_root = resolved_owner_root
129
+ owner_root ? File.join(owner_root, path_value) : path_value
130
+ end
131
+
132
+ def absolute_path?(path)
133
+ File.absolute_path(path) == path
134
+ end
135
+
136
+ def resolved_owner_root
137
+ return nil unless @owner.respond_to?(:root)
138
+
139
+ @owner.root.to_s
140
+ end
141
+
142
+ def path_like?(value)
143
+ value.is_a?(String) || value.respond_to?(:to_path)
144
+ end
145
+
146
+ def evaluate_builder_block(builder, &block)
147
+ if block.arity.zero?
148
+ builder.instance_eval(&block)
149
+ else
150
+ block.call(builder)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ class Container
6
+ attr_reader :config, :registry
7
+
8
+ def initialize(config:)
9
+ @config = config
10
+ @registry = Registry.new
11
+ @compiled_contracts = {}
12
+ @contractable_runners = {}
13
+ register_configured_contracts
14
+ discover_configured_contracts
15
+ end
16
+
17
+ def profile
18
+ @profile ||= Igniter::Contracts.build_profile(config.packs)
19
+ end
20
+
21
+ def register(name_or_definition, definition = nil, as: nil, &block)
22
+ name, contract_definition = normalize_registration(name_or_definition, definition, as: as, block: block)
23
+ raise ArgumentError, "contract definition is required" unless contract_definition
24
+
25
+ registry.register(name, contract_definition)
26
+ compiled_contracts.delete(name)
27
+ ContractHandle.new(name: name, container: self)
28
+ end
29
+
30
+ def fetch(name)
31
+ registry.fetch(name)
32
+ ContractHandle.new(name: name, container: self)
33
+ end
34
+
35
+ def compile(name = nil, &block)
36
+ return compile_block(&block) if block
37
+
38
+ compile_registered(name)
39
+ end
40
+
41
+ def call(name, inputs = {}, **keyword_inputs)
42
+ normalized_inputs = inputs.merge(keyword_inputs)
43
+ compiled_graph = compile_registered(name)
44
+ result = Igniter::Contracts.execute_with(
45
+ config.executor_name,
46
+ compiled_graph,
47
+ inputs: normalized_inputs,
48
+ profile: profile
49
+ )
50
+ ExecutionEnvelope.new(name: name, inputs: normalized_inputs, result: result)
51
+ rescue StandardError => e
52
+ raise unless config.capture_exceptions?
53
+
54
+ ExecutionEnvelope.new(
55
+ name: name,
56
+ inputs: normalized_inputs || {},
57
+ errors: [e],
58
+ metadata: { captured_exception: true }
59
+ )
60
+ end
61
+
62
+ def contractable(name)
63
+ key = name.to_sym
64
+ return contractable_runners.fetch(key) if contractable_runners.key?(key)
65
+
66
+ contractable_config = config.contractable_config(key)
67
+ raise UnknownContractableError, "unknown contractable #{key}" unless contractable_config
68
+
69
+ contractable_runners[key] = Contractable::Runner.new(config: contractable_config)
70
+ end
71
+ alias fetch_contractable contractable
72
+
73
+ def contractable_names
74
+ config.contractable_configs.map(&:name)
75
+ end
76
+
77
+ def clear_cache
78
+ compiled_contracts.clear
79
+ contractable_runners.clear
80
+ @profile = nil
81
+ self
82
+ end
83
+
84
+ def reload!
85
+ clear_cache
86
+ end
87
+
88
+ def sugar_expansion
89
+ config.sugar_expansion
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :compiled_contracts, :contractable_runners
95
+
96
+ def discover_configured_contracts
97
+ return unless config.discovery_enabled?
98
+
99
+ root = config.root
100
+ raise DiscoveryError, "config.root is required when discovery is enabled" unless root
101
+ raise DiscoveryError, "contract discovery root does not exist: #{root}" unless Dir.exist?(root)
102
+
103
+ before = contract_classes
104
+ Dir[File.join(root, config.discovery_pattern)].sort.each { |path| require path }
105
+ discovered_contract_classes = (contract_classes - before).select { |klass| discoverable_contract_class?(klass) }
106
+ discovered_by_name = discovered_contract_classes.group_by { |klass| ContractNaming.infer_contract_name(klass) }
107
+ duplicates = discovered_by_name.select { |_name, classes| classes.length > 1 }
108
+ unless duplicates.empty?
109
+ duplicate_names = duplicates.keys.sort.map { |name| ":#{name}" }.join(", ")
110
+ raise DiscoveryError,
111
+ "discovered duplicate contract names #{duplicate_names}; use explicit config.contract registrations"
112
+ end
113
+
114
+ discovered_by_name.keys.sort.each do |name|
115
+ next if registry.key?(name)
116
+
117
+ register(discovered_by_name.fetch(name).first, as: name)
118
+ end
119
+ end
120
+
121
+ def contract_classes
122
+ ObjectSpace.each_object(Class).select { |klass| ContractNaming.contract_class?(klass) }
123
+ end
124
+
125
+ def discoverable_contract_class?(contract_class)
126
+ !contract_class.name.nil?
127
+ end
128
+
129
+ def register_configured_contracts
130
+ config.contract_registrations.each do |registration|
131
+ register(registration.definition, as: registration.name)
132
+ end
133
+ end
134
+
135
+ def normalize_registration(name_or_definition, definition, as:, block:)
136
+ if ContractNaming.contract_class?(name_or_definition)
137
+ name = ContractNaming.normalize_contract_name(as || ContractNaming.infer_contract_name(name_or_definition))
138
+ return [name, name_or_definition]
139
+ end
140
+
141
+ name = ContractNaming.normalize_contract_name(as || name_or_definition)
142
+ [name, definition || block]
143
+ end
144
+
145
+ def compile_block(&block)
146
+ Igniter::Contracts.compile(profile: profile, &block)
147
+ end
148
+
149
+ def compile_registered(name)
150
+ key = name.to_sym
151
+ return compiled_contracts.fetch(key) if config.cache? && compiled_contracts.key?(key)
152
+
153
+ compiled = compile_registration(registry.fetch(key))
154
+ compiled_contracts[key] = compiled if config.cache?
155
+ compiled
156
+ end
157
+
158
+ def compile_registration(registration)
159
+ return compile_block(&registration.definition) if registration.block?
160
+
161
+ registration.definition.compile(profile: profile)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Embed
5
+ class ContractHandle
6
+ attr_reader :name, :container
7
+
8
+ def initialize(name:, container:)
9
+ @name = name.to_sym
10
+ @container = container
11
+ freeze
12
+ end
13
+
14
+ def compile
15
+ container.compile(name)
16
+ end
17
+
18
+ def call(inputs = {}, **keyword_inputs)
19
+ container.call(name, inputs.merge(keyword_inputs))
20
+ end
21
+ end
22
+ end
23
+ end