vetra 0.1.0.pre.alpha.4
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/bin/vetra +6 -0
- data/docs/ir/v0.1.schema.json +941 -0
- data/docs/ir/v0.2.schema.json +951 -0
- data/docs/ir/v0.3.schema.json +961 -0
- data/lib/vetra/cli.rb +64 -0
- data/lib/vetra/graph/analysis.rb +94 -0
- data/lib/vetra/graph/canonical.rb +52 -0
- data/lib/vetra/graph/capabilities.rb +22 -0
- data/lib/vetra/graph/compiler.rb +119 -0
- data/lib/vetra/graph/currency.rb +28 -0
- data/lib/vetra/graph/data.rb +48 -0
- data/lib/vetra/graph/declaration_loader.rb +62 -0
- data/lib/vetra/graph/diagnostic.rb +23 -0
- data/lib/vetra/graph/diff.rb +412 -0
- data/lib/vetra/graph/diff_source.rb +51 -0
- data/lib/vetra/graph/dsl.rb +444 -0
- data/lib/vetra/graph/linked_graph.rb +26 -0
- data/lib/vetra/graph/linker.rb +95 -0
- data/lib/vetra/graph/live_dependencies.rb +48 -0
- data/lib/vetra/graph/manifest.rb +78 -0
- data/lib/vetra/graph/normalizer.rb +689 -0
- data/lib/vetra/graph/registry.rb +50 -0
- data/lib/vetra/graph/schema.rb +78 -0
- data/lib/vetra/graph/validator.rb +600 -0
- data/lib/vetra/version.rb +3 -0
- data/lib/vetra.rb +29 -0
- metadata +141 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
module DSL
|
|
4
|
+
class Base < BasicObject
|
|
5
|
+
def self.const_missing(name)
|
|
6
|
+
::Object.const_get(name)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(registry, manifest, domain, source)
|
|
10
|
+
@registry, @manifest, @domain, @source = registry, manifest, domain, source
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def evaluate(block)
|
|
14
|
+
instance_eval(&block) if block
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def evaluate_source(declaration_source, declaration_filename)
|
|
19
|
+
instance_eval(declaration_source, declaration_filename, 1)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def method_missing(name, *_args, **_options, &_block)
|
|
23
|
+
error("unsupported_declaration", "Declaração #{name} não implementada no protótipo; nenhum dado foi descartado.")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def error(code, message, origin: @source)
|
|
27
|
+
::Kernel.raise Error.single(code, message, source: origin)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def source(block)
|
|
31
|
+
return @manifest.source(*block.source_location) if block
|
|
32
|
+
filename = @manifest.root.join(@source['path']).to_s
|
|
33
|
+
frame = ::Kernel.caller_locations.find { |entry| (entry.absolute_path || entry.path) == filename }
|
|
34
|
+
frame ? @manifest.source(filename, frame.lineno) : @source
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def qualify(name)
|
|
38
|
+
value = name.to_s
|
|
39
|
+
value.include?(".") ? value : "#{@domain}.#{value}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def declaration(kind, name, attributes)
|
|
43
|
+
error("domain_required", "Declare nós dentro de app/domains/<domínio>/.") unless @domain
|
|
44
|
+
{ "kind" => kind, "id" => qualify(name), "domain" => @domain, "source" => @source, **attributes }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def options!(options, allowed)
|
|
48
|
+
unknown = options.keys - allowed
|
|
49
|
+
error("unsupported_option", "Opções não implementadas: #{unknown.join(', ')}.") unless unknown.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class App < Base
|
|
54
|
+
attr_reader :attributes
|
|
55
|
+
|
|
56
|
+
def initialize(*args, name:)
|
|
57
|
+
super(*args)
|
|
58
|
+
@attributes = { "name" => name.to_s, "currency" => "BRL", "locale" => "pt-BR", "tenant" => nil, "source" => @source }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def currency(value, **options)
|
|
62
|
+
options!(options, [])
|
|
63
|
+
@attributes["currency"] = value.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def locale(value)
|
|
67
|
+
@attributes["locale"] = value.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def tenant(value)
|
|
71
|
+
@attributes["tenant"] = value.to_s
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class Domain < Base
|
|
76
|
+
attr_reader :dependencies
|
|
77
|
+
|
|
78
|
+
def initialize(*args)
|
|
79
|
+
super
|
|
80
|
+
@dependencies = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def depends_on(*names)
|
|
84
|
+
@dependencies = names.map(&:to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Fields < Base
|
|
89
|
+
attr_reader :fields
|
|
90
|
+
TYPES = %w[string text email boolean integer date timestamp json id decimal money].freeze
|
|
91
|
+
|
|
92
|
+
def initialize(*args)
|
|
93
|
+
super
|
|
94
|
+
@fields = []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
TYPES.each do |type|
|
|
98
|
+
define_method(type) { |name, **options| field(name, type, **options) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def enum(name, values, **options)
|
|
102
|
+
field(name, "enum", **options.merge(values: values))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def slug(name, from:, **options)
|
|
106
|
+
field(name, "string", **options.merge(generate: { "kind" => "slug", "from" => from.to_s }))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def field(name, type, **options)
|
|
110
|
+
options!(options, %i[required optional nullable default personal classification set_by guarded values groups precision scale entity min max length unique normalize generate was])
|
|
111
|
+
error("duplicate_field", "Campo repetido: #{name}.") if fields.any? { |entry| entry['name'] == name.to_s }
|
|
112
|
+
optional = options.fetch(:optional, false)
|
|
113
|
+
descriptor = { "name" => type.to_s }
|
|
114
|
+
descriptor["entity"] = options[:entity].to_s if options.key?(:entity)
|
|
115
|
+
descriptor["values"] = options.fetch(:values).map(&:to_s) if type.to_s == "enum"
|
|
116
|
+
descriptor["groups"] = options[:groups].to_h { |key, values| [key.to_s, values.map(&:to_s)] } if options[:groups]
|
|
117
|
+
if %w[money decimal].include?(type.to_s)
|
|
118
|
+
descriptor["precision"] = options.fetch(:precision, 19)
|
|
119
|
+
# Money's implicit scale depends on the application, resolved during normalization.
|
|
120
|
+
descriptor["scale"] = options.fetch(:scale, 2) if type.to_s == "decimal" || options.key?(:scale)
|
|
121
|
+
end
|
|
122
|
+
personal = options.fetch(:personal, false)
|
|
123
|
+
privacy = { "personal" => personal }
|
|
124
|
+
privacy["classification"] = options.fetch(:classification, "personal").to_s if personal
|
|
125
|
+
entry = {
|
|
126
|
+
"name" => name.to_s, "type" => descriptor,
|
|
127
|
+
"required" => options.fetch(:required, !optional && !options.key?(:default) && !options.key?(:generate)),
|
|
128
|
+
"nullable" => options.fetch(:nullable, optional || (type.to_s == "timestamp" && options.key?(:set_by))),
|
|
129
|
+
"privacy" => privacy
|
|
130
|
+
}
|
|
131
|
+
if options.key?(:default)
|
|
132
|
+
value = options[:default]
|
|
133
|
+
entry["default"] = if %i[current_actor current_tenant].include?(value)
|
|
134
|
+
{ "kind" => "context", "name" => value.to_s }
|
|
135
|
+
else
|
|
136
|
+
{ "kind" => "literal", "value" => json_value(value) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
entry["set_by"] = ::Kernel.Array(options[:set_by]).map(&:to_s) if options.key?(:set_by)
|
|
140
|
+
entry["guarded"] = options[:guarded].map { |value, commands| { "value" => value.to_s, "commands" => ::Kernel.Array(commands).map(&:to_s) } } if options[:guarded]
|
|
141
|
+
entry["previous_names"] = ::Kernel.Array(options[:was]).map(&:to_s) if options.key?(:was)
|
|
142
|
+
entry["normalize"] = ::Kernel.Array(options[:normalize]).map(&:to_s) if options.key?(:normalize)
|
|
143
|
+
entry["generate"] = options[:generate] if options.key?(:generate)
|
|
144
|
+
constraints = {}
|
|
145
|
+
constraints["min_length"] = options[:min] if options.key?(:min)
|
|
146
|
+
constraints["max_length"] = options[:max] if options.key?(:max)
|
|
147
|
+
if options[:length]
|
|
148
|
+
range = options[:length]
|
|
149
|
+
error("unsupported_option", "length exige Range de inteiros inclusivo.") unless range.is_a?(::Range) && !range.exclude_end?
|
|
150
|
+
constraints.merge!("min_length" => range.begin, "max_length" => range.end)
|
|
151
|
+
end
|
|
152
|
+
constraints["unique"] = options[:unique] if options.key?(:unique)
|
|
153
|
+
entry["constraints"] = constraints unless constraints.empty?
|
|
154
|
+
@fields << entry
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def json_value(value)
|
|
158
|
+
case value
|
|
159
|
+
when ::Hash then value.to_h { |key, child| [key.to_s, json_value(child)] }
|
|
160
|
+
when ::Array then value.map { |child| json_value(child) }
|
|
161
|
+
when ::Symbol then value.to_s
|
|
162
|
+
else value
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
class Entity < Fields
|
|
168
|
+
attr_reader :attributes
|
|
169
|
+
|
|
170
|
+
def initialize(*args, options:)
|
|
171
|
+
super(*args)
|
|
172
|
+
options!(options, %i[table repository was])
|
|
173
|
+
@attributes = { "fields" => fields, "relations" => [], "indexes" => [], "history" => "full", "transitions" => { "enforce" => "application" }, "options" => options }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def tenant(value)
|
|
177
|
+
@attributes["tenant"] = value == false ? false : value.to_s
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def belongs_to(name, entity: name, optional: false, same_tenant: false, **options)
|
|
181
|
+
options!(options, %i[default])
|
|
182
|
+
field("#{name}_id", "id", entity: entity, optional: optional, **options)
|
|
183
|
+
@attributes["relations"] << { "name" => name.to_s, "target" => entity.to_s, "kind" => "belongs_to", "same_tenant" => same_tenant }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def has_many(name, entity:, by:)
|
|
187
|
+
@attributes["relations"] << { "name" => name.to_s, "target" => entity.to_s, "kind" => "has_many", "by" => by.to_s, "same_tenant" => false }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# `entity:` e `through:` são opcionais: sem `entity:`, o alvo é a entidade do mesmo domínio com o nome da
|
|
191
|
+
# relação no singular; sem `through:`, o normalizador gera a entidade de junção (ver Normalizer).
|
|
192
|
+
def many_to_many(name, entity: nil, through: nil, include: nil)
|
|
193
|
+
@attributes["relations"] << {
|
|
194
|
+
"name" => name.to_s, "target" => entity&.to_s, "kind" => "many_to_many", "through" => through&.to_s,
|
|
195
|
+
"include" => ::Kernel.Array(include).map(&:to_s), "same_tenant" => false
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def memberships(of:, actor:, role:)
|
|
200
|
+
error("duplicate_memberships", "Declare memberships uma única vez por entidade.") if @attributes.key?("memberships")
|
|
201
|
+
@attributes["memberships"] = { "of" => of.to_s, "actor" => actor.to_s, "role" => role.to_s }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def index(*names, unique: false)
|
|
205
|
+
@attributes["indexes"] << { "fields" => names.map(&:to_s), "unique" => unique }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def crud(scope: nil)
|
|
209
|
+
@attributes["crud"] = { "scope" => scope&.to_s }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def history(value)
|
|
213
|
+
@attributes["history"] = value.to_s
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def transitions(enforce:)
|
|
217
|
+
@attributes["transitions"] = { "enforce" => enforce.to_s }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
class Output < Fields
|
|
222
|
+
def record(name, entity:, optional: false)
|
|
223
|
+
field(name, 'record', entity: entity, optional: optional)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def collection(name, entity:, optional: false)
|
|
227
|
+
field(name, 'collection', entity: entity, optional: optional)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
class Operation < Fields
|
|
232
|
+
attr_reader :attributes, :implementation
|
|
233
|
+
|
|
234
|
+
def initialize(*args)
|
|
235
|
+
super
|
|
236
|
+
@attributes = { "fields" => fields, "uses" => [], "emits" => [], "exposures" => [], "on" => [], "calls" => [] }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def input(name, type, **options)
|
|
240
|
+
field(name, type.to_s, **options)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def returns(entity = nil, collection: false, &block)
|
|
244
|
+
error('output_contract', 'Declare returns uma única vez, com entidade ou bloco.') if @attributes['returns'] || (block && entity) || (!block && !entity)
|
|
245
|
+
error('output_contract', 'collection exige true ou false e não pode acompanhar um bloco.') unless [true, false].include?(collection) && !(block && collection)
|
|
246
|
+
@attributes['returns'] = if block
|
|
247
|
+
builder = Output.new(@registry, @manifest, @domain, source(block)).evaluate(block)
|
|
248
|
+
{ 'fields' => builder.fields }
|
|
249
|
+
elsif collection
|
|
250
|
+
{ 'entity' => entity.to_s, 'collection' => true }
|
|
251
|
+
else
|
|
252
|
+
entity.to_s
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def authorize(action, entity, record: nil)
|
|
257
|
+
@attributes["authorize"] = { "action" => action.to_s, "entity" => entity.to_s }
|
|
258
|
+
@attributes["authorize"]["record"] = record.to_s if record
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def uses(*names)
|
|
262
|
+
@attributes["uses"].concat(names.map(&:to_s))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def live(value = true, **options)
|
|
266
|
+
origin = source(nil)
|
|
267
|
+
if @attributes.key?('live') || ![true, false].include?(value)
|
|
268
|
+
error('live_declaration', 'Declare live uma única vez, com true ou false.', origin: origin)
|
|
269
|
+
end
|
|
270
|
+
unknown = options.keys - %i[mode depends_on]
|
|
271
|
+
error('unsupported_option', "Opções não implementadas em live: #{unknown.join(', ')}.", origin: origin) unless unknown.empty?
|
|
272
|
+
error('live_declaration', 'live false não aceita opções adicionais.', origin: origin) if !value && !options.empty?
|
|
273
|
+
mode = options.fetch(:mode, :strict).to_s
|
|
274
|
+
error('live_declaration', 'live exige mode: :strict ou :balanced.', origin: origin) unless %w[strict balanced].include?(mode)
|
|
275
|
+
declaration = { 'enabled' => value, 'mode' => mode }
|
|
276
|
+
if options.key?(:depends_on)
|
|
277
|
+
names = options[:depends_on]
|
|
278
|
+
unless names.is_a?(::Array) && !names.empty? && names.all? { |name| (name.is_a?(::String) || name.is_a?(::Symbol)) && !name.to_s.strip.empty? }
|
|
279
|
+
error('live_dependencies', 'depends_on de live exige uma lista não vazia de entidades.', origin: origin)
|
|
280
|
+
end
|
|
281
|
+
declaration['depends_on'] = names.map(&:to_s)
|
|
282
|
+
end
|
|
283
|
+
@attributes['live'] = declaration
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def emits(event, with:, from: nil)
|
|
287
|
+
@attributes["emits"] << { "event" => event.to_s, "fields" => with.map(&:to_s), "from" => from&.to_s }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def expose(idempotency: :optional, **surfaces)
|
|
291
|
+
origin = source(nil)
|
|
292
|
+
unknown = surfaces.keys - %i[http cli job webhook]
|
|
293
|
+
unless unknown.empty?
|
|
294
|
+
::Kernel.raise Error.single('unsupported_option', "Opções não implementadas em expose: #{unknown.join(', ')}.", source: origin)
|
|
295
|
+
end
|
|
296
|
+
if surfaces.empty?
|
|
297
|
+
::Kernel.raise Error.single('missing_option', 'Declare ao menos uma superfície em expose: http, cli, job ou webhook.', source: origin)
|
|
298
|
+
end
|
|
299
|
+
surfaces.each do |surface, value|
|
|
300
|
+
named = %i[cli job].include?(surface)
|
|
301
|
+
valid = value.is_a?(::String) || (named && value.is_a?(::Symbol))
|
|
302
|
+
unless valid && !value.to_s.strip.empty?
|
|
303
|
+
format = named ? 'um nome não vazio (String ou Symbol)' : '"MÉTODO /caminho" (String)'
|
|
304
|
+
::Kernel.raise Error.single('route_format', "expose #{surface} exige #{format}.", source: origin)
|
|
305
|
+
end
|
|
306
|
+
@attributes['exposures'] << { 'surface' => surface.to_s, 'value' => value.to_s, 'idempotency' => idempotency.to_s, 'source' => origin }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def call(&block)
|
|
311
|
+
error("duplicate_implementation", "Declare apenas um bloco call.") if @implementation
|
|
312
|
+
@implementation = block
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def unsafe_effect(reason)
|
|
316
|
+
error("unsafe_reason", "unsafe_effect exige motivo não vazio.") if reason.to_s.strip.empty?
|
|
317
|
+
@attributes["unsafe_effect"] = reason.to_s
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def on(*events)
|
|
321
|
+
@attributes["on"].concat(events.map(&:to_s))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def calls(*commands)
|
|
325
|
+
@attributes["calls"].concat(commands.map(&:to_s))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def idempotent_by(value)
|
|
329
|
+
@attributes["idempotent_by"] = value.to_s
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
class Role < Base
|
|
334
|
+
attr_reader :permissions
|
|
335
|
+
|
|
336
|
+
def initialize(*args, scope: nil)
|
|
337
|
+
super(*args)
|
|
338
|
+
@scope = scope
|
|
339
|
+
@permissions = []
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def can(*actions, on:, where: {}, fields: "*", scope: @scope, **options)
|
|
343
|
+
options!(options, [])
|
|
344
|
+
::Kernel.Array(on).each do |entity|
|
|
345
|
+
@permissions << { "entity" => entity.to_s, "actions" => actions.map(&:to_s), "where" => where, "fields" => fields == "*" ? "*" : fields.map(&:to_s), "scope" => scope&.to_s }
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
class Roles < Base
|
|
351
|
+
def initialize(*args, scope: nil)
|
|
352
|
+
super(*args)
|
|
353
|
+
@scope = scope
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def role(name, anonymous: false, &block)
|
|
357
|
+
origin = source(block)
|
|
358
|
+
builder = Role.new(@registry, @manifest, @domain, origin, scope: @scope).evaluate(block)
|
|
359
|
+
@registry.add(declaration("role", name, "source" => origin, "anonymous" => anonymous, "permissions" => builder.permissions))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def visitor(&block)
|
|
363
|
+
role(:visitor, anonymous: true, &block)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def authenticated(&block)
|
|
367
|
+
role(:authenticated, &block)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
class Effect < Base
|
|
372
|
+
attr_reader :attributes
|
|
373
|
+
|
|
374
|
+
def initialize(*args)
|
|
375
|
+
super
|
|
376
|
+
@attributes = { "adapter" => "app.effect", "retry_safe" => false, "simulation" => "none", "config" => { "type" => "external", "idempotency_key" => false, "hosts" => [], "timeout_ms" => 5000 } }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def adapter(value)
|
|
380
|
+
@attributes["adapter"] = value.to_s
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def retry_safe(value)
|
|
384
|
+
@attributes["retry_safe"] = value
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def simulate(value)
|
|
388
|
+
@attributes["simulation"] = value.to_s
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def idempotency_key(value)
|
|
392
|
+
@attributes["config"]["idempotency_key"] = value
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def hosts(*values)
|
|
396
|
+
@attributes["config"]["hosts"] = values
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def timeout(seconds)
|
|
400
|
+
@attributes["config"]["timeout_ms"] = seconds * 1000
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
class Context < Base
|
|
405
|
+
def app(name, &block)
|
|
406
|
+
builder = App.new(@registry, @manifest, nil, source(block), name: name).evaluate(block)
|
|
407
|
+
@registry.app(builder.attributes)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def domain(name, &block)
|
|
411
|
+
error("domain_mismatch", "Domínio deve coincidir com o diretório #{@domain}.") unless name.to_s == @domain
|
|
412
|
+
builder = Domain.new(@registry, @manifest, @domain, source(block)).evaluate(block)
|
|
413
|
+
@registry.add({ "kind" => "domain", "id" => @domain, "source" => source(block), "depends_on" => builder.dependencies })
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def entity(name, **options, &block)
|
|
417
|
+
builder = Entity.new(@registry, @manifest, @domain, source(block), options: options).evaluate(block)
|
|
418
|
+
@registry.add(declaration("entity", name, builder.attributes.merge("source" => source(block))))
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
%w[command query handler].each do |kind|
|
|
422
|
+
define_method(kind) do |name, &block|
|
|
423
|
+
builder = Operation.new(@registry, @manifest, @domain, source(block)).evaluate(block)
|
|
424
|
+
error("missing_implementation", "Declare call em #{name}.") unless builder.implementation
|
|
425
|
+
@registry.add(declaration(kind, name, builder.attributes.merge("source" => source(block))), block: builder.implementation)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def roles(scope: nil, &block)
|
|
430
|
+
Roles.new(@registry, @manifest, @domain, source(block), scope: scope).evaluate(block)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def effect(name, &block)
|
|
434
|
+
builder = Effect.new(@registry, @manifest, @domain, source(block)).evaluate(block)
|
|
435
|
+
@registry.add(declaration("capability", name, builder.attributes.merge("source" => source(block))))
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def event(name, from:, fields:, visibility: :internal, consumer_required: false, version: 1)
|
|
439
|
+
@registry.add(declaration("event", name, "source" => source(nil), "from" => from.to_s, "fields" => fields.map(&:to_s), "visibility" => visibility.to_s, "consumer_required" => consumer_required, "version" => version))
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
class LinkedGraph
|
|
4
|
+
attr_reader :document, :warnings, :bindings, :implementation_ids, :builtin_bindings
|
|
5
|
+
|
|
6
|
+
def initialize(document:, warnings:, bindings:, implementations:)
|
|
7
|
+
@document = Data.freeze(document)
|
|
8
|
+
@warnings = Data.freeze(warnings)
|
|
9
|
+
@bindings = Data.freeze(bindings.sort_by { |binding| binding['operation'].values_at('kind', 'id') })
|
|
10
|
+
@implementation_ids = Data.freeze(implementations.keys.sort)
|
|
11
|
+
@builtin_bindings = Data.freeze(@bindings.select { |binding| binding['kind'] == 'builtin' })
|
|
12
|
+
@binding_index = @bindings.to_h { |binding| [binding['operation'].values_at('kind', 'id').freeze, binding] }.freeze
|
|
13
|
+
# Executable closures stay private. Their captured state is not made
|
|
14
|
+
# shareable by freezing a Proc; only the data views are shareable.
|
|
15
|
+
@implementations = implementations.dup.freeze
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def binding_for(kind, id)
|
|
20
|
+
@binding_index.fetch([kind, id]) do
|
|
21
|
+
raise Error.single('binding_missing', "Vínculo não encontrado: #{kind} #{id}.")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
# Internal binder. Compiler#link performs artifact and package validation
|
|
4
|
+
# before constructing this object or evaluating project declarations.
|
|
5
|
+
class Linker
|
|
6
|
+
def initialize(document, manifest)
|
|
7
|
+
@document, @manifest = document, manifest
|
|
8
|
+
@nodes = document.fetch('nodes').to_h { |node| [node.values_at('kind', 'id'), node] }
|
|
9
|
+
@analysis = Analysis.new(manifest)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def link(registry, warnings: [])
|
|
13
|
+
unless registry.frozen? && registry.bindings.frozen? && registry.declarations.frozen?
|
|
14
|
+
raise Error.single('registry_unsealed', 'Sele o registro antes de vincular implementações.')
|
|
15
|
+
end
|
|
16
|
+
operations = @document['nodes'].select { |node| node.key?('implementation') }
|
|
17
|
+
ruby_nodes = operations.select { |node| node.dig('implementation', 'kind') == 'ruby' }
|
|
18
|
+
expected = ruby_nodes.map { |node| node.dig('implementation', 'ref') }
|
|
19
|
+
if expected.uniq != expected
|
|
20
|
+
raise Error.single('duplicate_implementation', 'Mais de uma operação usa a mesma referência de implementação.')
|
|
21
|
+
end
|
|
22
|
+
missing = expected - registry.bindings.keys
|
|
23
|
+
extra = registry.bindings.keys - expected
|
|
24
|
+
unless missing.empty?
|
|
25
|
+
raise Error.single('implementation_missing', "Implementações ausentes no registro: #{missing.sort.join(', ')}.")
|
|
26
|
+
end
|
|
27
|
+
unless extra.empty?
|
|
28
|
+
raise Error.single('implementation_unexpected', "Implementações sem operação na IR: #{extra.sort.join(', ')}.")
|
|
29
|
+
end
|
|
30
|
+
declarations = registry.declarations.group_by { |entry| entry.values_at('kind', 'id') }
|
|
31
|
+
bindings = operations.map do |node|
|
|
32
|
+
if node.dig('implementation', 'kind') == 'ruby'
|
|
33
|
+
ruby_binding(node, declarations, registry.bindings)
|
|
34
|
+
else
|
|
35
|
+
builtin_binding(node)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
LinkedGraph.new(document: @document, warnings: warnings, bindings: bindings, implementations: registry.bindings)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def fail!(code, message, node)
|
|
44
|
+
raise Error.single(code, message, source: node['source'])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ruby_binding(node, declarations, implementations)
|
|
48
|
+
descriptor = node['implementation']
|
|
49
|
+
candidates = declarations.fetch(node.values_at('kind', 'id'), [])
|
|
50
|
+
unless candidates.size == 1 && candidates.first['source'] == node['source'] && candidates.first['domain'] == node.dig('domain', 'id')
|
|
51
|
+
fail!('implementation_declaration', 'O vínculo exige uma única declaração correspondente à origem da operação.', node)
|
|
52
|
+
end
|
|
53
|
+
declaration = candidates.first
|
|
54
|
+
block = implementations.fetch(descriptor['ref'])
|
|
55
|
+
unless block.is_a?(Proc) && block.frozen? && block.source_location
|
|
56
|
+
fail!('implementation_source', 'A implementação deve ser um bloco Ruby congelado com origem verificável.', node)
|
|
57
|
+
end
|
|
58
|
+
begin
|
|
59
|
+
origin = @manifest.source(*block.source_location)
|
|
60
|
+
rescue Error, SystemCallError
|
|
61
|
+
fail!('implementation_source', 'O bloco não pertence aos fontes verificados do pacote.', node)
|
|
62
|
+
end
|
|
63
|
+
unless origin['path'] == descriptor['path'] && @manifest.entries.any? { |input| input['path'] == origin['path'] && input['digest'] == descriptor['digest'] }
|
|
64
|
+
fail!('implementation_source', 'Arquivo ou digest do bloco difere da implementação declarada na IR.', node)
|
|
65
|
+
end
|
|
66
|
+
report = @analysis.inspect_block(block, declaration['uses'], declaration_source: node['source'], declaration_kind: node['kind'])
|
|
67
|
+
unless report['analysis'] == descriptor['analysis']
|
|
68
|
+
fail!('implementation_analysis', 'Análise do bloco difere do artefato; reconstrua e revise o pacote.', node)
|
|
69
|
+
end
|
|
70
|
+
arguments = declaration['uses'].each_with_index.map do |name, i|
|
|
71
|
+
reference = Capabilities.reference(name, domain: declaration['domain'], operation_kind: node['kind'], nodes: @nodes, source: node['source'])
|
|
72
|
+
{ 'position' => i + 1, 'parameter' => name.split('.').last, 'capability' => reference }
|
|
73
|
+
end
|
|
74
|
+
references = arguments.map { |argument| argument['capability'] }.sort_by { |reference| reference.values_at('kind', 'id') }
|
|
75
|
+
unless references == node['uses'].sort_by { |reference| reference.values_at('kind', 'id') }
|
|
76
|
+
fail!('implementation_capabilities', 'As capacidades dos parâmetros diferem do conjunto uses da IR.', node)
|
|
77
|
+
end
|
|
78
|
+
{
|
|
79
|
+
'kind' => 'ruby', 'operation' => Data.reference(node['kind'], node['id']),
|
|
80
|
+
'ref' => descriptor['ref'], 'source' => report['source'],
|
|
81
|
+
'parameters' => block.parameters.map { |_, name| name.to_s }, 'arguments' => arguments
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def builtin_binding(node)
|
|
86
|
+
name = node.dig('implementation', 'name')
|
|
87
|
+
action = name.delete_prefix('crud.')
|
|
88
|
+
unless Schema::CRUD_ACTIONS.include?(action) && name == "crud.#{action}" && node['kind'] == (action == 'read' ? 'query' : 'command')
|
|
89
|
+
fail!('builtin_implementation', 'Primitiva desconhecida ou incompatível com a operação.', node)
|
|
90
|
+
end
|
|
91
|
+
{ 'kind' => 'builtin', 'operation' => Data.reference(node['kind'], node['id']), 'name' => name, 'executor' => 'pending' }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
module LiveDependencies
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def outside_boundary?(query, nodes:)
|
|
7
|
+
query.key?('unsafe_effect') || query['uses'].any? do |reference|
|
|
8
|
+
!%w[repository deterministic].include?(nodes.fetch(reference.values_at('kind', 'id')).dig('config', 'type'))
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def required(query, nodes:, extra: [])
|
|
13
|
+
entities = [query.dig('authorize', 'entity'), *extra]
|
|
14
|
+
query['uses'].each do |reference|
|
|
15
|
+
config = nodes.fetch(reference.values_at('kind', 'id'))['config']
|
|
16
|
+
entities << config['entity'] if config['type'] == 'repository'
|
|
17
|
+
end
|
|
18
|
+
pending_contracts = [query.dig('output', 'id')]
|
|
19
|
+
contracts = {}
|
|
20
|
+
until pending_contracts.empty?
|
|
21
|
+
id = pending_contracts.pop
|
|
22
|
+
next if contracts[id]
|
|
23
|
+
contracts[id] = true
|
|
24
|
+
contract = nodes.fetch(['contract', id])
|
|
25
|
+
origin = contract['generated_from']
|
|
26
|
+
entities << origin if origin && origin['kind'] == 'entity'
|
|
27
|
+
Data.references(contract['fields']) do |reference, _pointer|
|
|
28
|
+
entities << reference if reference['kind'] == 'entity'
|
|
29
|
+
pending_contracts << reference['id'] if reference['kind'] == 'contract'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
policies = nodes.values.select { |node| node['kind'] == 'policy' }.group_by { |node| node.dig('entity', 'id') }
|
|
33
|
+
found = {}
|
|
34
|
+
until entities.empty?
|
|
35
|
+
reference = entities.pop
|
|
36
|
+
id = reference.fetch('id')
|
|
37
|
+
next if found[id]
|
|
38
|
+
found[id] = reference
|
|
39
|
+
policies.fetch(id, []).each do |policy|
|
|
40
|
+
authorized = id == query.dig('authorize', 'entity', 'id') && policy['actions'].include?(query.dig('authorize', 'action'))
|
|
41
|
+
entities.concat(policy['reads']) if authorized || policy['actions'].include?('read')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
found.values.sort_by { |reference| reference.values_at('kind', 'id') }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|