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,689 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
class Normalizer
|
|
4
|
+
ACTIONS = Schema::CRUD_ACTIONS
|
|
5
|
+
|
|
6
|
+
def initialize(registry, manifest, ir_version: Schema::VERSION)
|
|
7
|
+
@registry, @manifest = registry, manifest
|
|
8
|
+
@ir_version = ir_version
|
|
9
|
+
@raw = expand_implicit_junctions(registry.declarations)
|
|
10
|
+
@entities = {}
|
|
11
|
+
@nodes = {}
|
|
12
|
+
@analysis = Analysis.new(manifest)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def document
|
|
16
|
+
app = @registry.application
|
|
17
|
+
raise Error.single('app_missing', 'Declare Vetra.app em config/app.rb.') unless app
|
|
18
|
+
@currency = app['currency']
|
|
19
|
+
@application_tenant = application_tenant(app['tenant'])
|
|
20
|
+
domains
|
|
21
|
+
raw('entity').each { |entry| entity(entry) }
|
|
22
|
+
raw('capability').each { |entry| add(base(entry).merge(entry.slice('adapter', 'retry_safe', 'simulation', 'config'))) }
|
|
23
|
+
raw('entity').each { |entry| entity_artifacts(entry) }
|
|
24
|
+
raw('role').each { |entry| role(entry) }
|
|
25
|
+
raw('event').each { |entry| event(entry) }
|
|
26
|
+
%w[command query handler].each { |kind| raw(kind).each { |entry| operation(entry) } }
|
|
27
|
+
result = {
|
|
28
|
+
'ir_version' => @ir_version, 'requires' => timestamps? ? %w[vetra.core vetra.timestamps] : ['vetra.core'],
|
|
29
|
+
'producer' => { 'name' => 'vetra', 'version' => Vetra::VERSION, 'ruby_version' => RUBY_VERSION },
|
|
30
|
+
'app' => app.slice('name', 'currency', 'locale').merge('tenant' => @application_tenant),
|
|
31
|
+
'inputs' => Data.copy(@manifest.entries),
|
|
32
|
+
'nodes' => @nodes.values.sort_by { |node| node.values_at('kind', 'id') }
|
|
33
|
+
}
|
|
34
|
+
result['requires'] << 'vetra.structured_outputs' if @structured_outputs
|
|
35
|
+
result['requires'] << 'vetra.live' if @live
|
|
36
|
+
result['requires'].sort!
|
|
37
|
+
result = Data.copy(result)
|
|
38
|
+
sort_sets(result)
|
|
39
|
+
result['digest'] = Canonical.digest(result)
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def timestamps?
|
|
46
|
+
@ir_version != Schema::LEGACY_VERSION
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def raw(kind)
|
|
50
|
+
@raw.select { |entry| entry['kind'] == kind }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Um many_to_many sem `through:` ganha uma entidade de junção gerada, com a forma que a aplicação declararia à
|
|
54
|
+
# mão, e a relação passa a apontar para ela: dali em diante vale o caminho da junção explícita. Sem `entity:`, o
|
|
55
|
+
# alvo é a entidade do mesmo domínio com o nome da relação no singular. A junção gerada é um nó comum da IR,
|
|
56
|
+
# com `generated_from` apontando o dono. Roda antes de @raw existir, então só usa variáveis locais.
|
|
57
|
+
def expand_implicit_junctions(declarations)
|
|
58
|
+
entities = declarations.select { |entry| entry['kind'] == 'entity' }
|
|
59
|
+
tenant_ref = @registry.application&.dig('tenant')
|
|
60
|
+
tenant_id = tenant_ref && entities.find { |item| item['id'] == tenant_ref || (!tenant_ref.include?('.') && item['id'].split('.').last == tenant_ref) }&.dig('id')
|
|
61
|
+
generated = []
|
|
62
|
+
expanded = declarations.map do |entry|
|
|
63
|
+
implicit = entry['kind'] == 'entity' && entry['relations'].any? { |relation| relation['kind'] == 'many_to_many' && (relation['target'].nil? || relation['through'].nil?) }
|
|
64
|
+
next entry unless implicit
|
|
65
|
+
entry = entry.merge('relations' => entry['relations'].map(&:dup))
|
|
66
|
+
entry['relations'].each do |relation|
|
|
67
|
+
next unless relation['kind'] == 'many_to_many'
|
|
68
|
+
relation['target'] ||= inferred_target(entry, relation, entities)
|
|
69
|
+
next if relation['through']
|
|
70
|
+
junction = junction_declaration(entry, relation, entities + generated, tenant_id)
|
|
71
|
+
generated << junction
|
|
72
|
+
relation['through'] = junction['id']
|
|
73
|
+
end
|
|
74
|
+
entry
|
|
75
|
+
end
|
|
76
|
+
expanded + generated
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inferred_target(entry, relation, entities)
|
|
80
|
+
id = "#{entry['domain']}.#{singular(relation['name'])}"
|
|
81
|
+
unless entities.any? { |item| item['id'] == id }
|
|
82
|
+
fail!('relation_target', "many_to_many :#{relation['name']} sem entity: exige a entidade #{id}; declare-a ou informe entity:.", entry)
|
|
83
|
+
end
|
|
84
|
+
id
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def junction_declaration(owner, relation, entities, tenant_id)
|
|
88
|
+
target_id = qualify(relation['target'], owner['domain'])
|
|
89
|
+
target = entities.find { |item| item['id'] == target_id }
|
|
90
|
+
fail!('reference_missing', "Declare entity #{target_id} ou use referência qualificada ao domínio correto.", owner) unless target
|
|
91
|
+
if owner['id'] == target['id']
|
|
92
|
+
fail!('relation_through', "many_to_many :#{relation['name']} liga #{owner['id']} a ela mesma: auto-relações não são suportadas, pois a IR não registra qual coluna é qual; declare a junção e through:.", owner)
|
|
93
|
+
end
|
|
94
|
+
id = "#{owner['domain']}.#{owner['id'].split('.').last}_#{singular(relation['name'])}"
|
|
95
|
+
if entities.any? { |item| item['id'] == id }
|
|
96
|
+
fail!('relation_through', "A junção gerada #{id} colide com uma entidade declarada; use through: :#{id.split('.').last}.", owner)
|
|
97
|
+
end
|
|
98
|
+
scoped = [owner, target].map { |item| !tenant_id.nil? && item['id'] != tenant_id && item['tenant'] != false }
|
|
99
|
+
if scoped.uniq.size > 1
|
|
100
|
+
fail!('relation_through', "A junção gerada de #{owner['id']} para #{target['id']} exige entidades com o mesmo tratamento de tenant; declare a junção e through:.", owner)
|
|
101
|
+
end
|
|
102
|
+
shared = scoped.first
|
|
103
|
+
owner_name, target_name = owner['id'].split('.').last, target['id'].split('.').last
|
|
104
|
+
builder = DSL::Entity.new(@registry, @manifest, owner['domain'], Data.copy(owner['source']), options: {})
|
|
105
|
+
builder.tenant(false) unless shared
|
|
106
|
+
builder.belongs_to(owner_name, entity: owner['id'], same_tenant: shared)
|
|
107
|
+
builder.belongs_to(target_name, entity: target['id'], same_tenant: shared)
|
|
108
|
+
builder.index(*(shared ? ["#{tenant_id.split('.').last}_id"] : []), "#{owner_name}_id", "#{target_name}_id", unique: true)
|
|
109
|
+
{ 'kind' => 'entity', 'id' => id, 'domain' => owner['domain'], 'source' => Data.copy(owner['source']),
|
|
110
|
+
'generated_from' => Data.reference('entity', owner['id']) }.merge(builder.attributes)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Inverso mínimo de plural: "ies" depois de consoante vira "y"; um "s" final cai, exceto em "ss". Quando não
|
|
114
|
+
# acerta, o erro manda informar entity:.
|
|
115
|
+
def singular(name)
|
|
116
|
+
return "#{name[0...-3]}y" if name.match?(/[^aeiou]ies\z/)
|
|
117
|
+
return name[0...-1] if name.end_with?('s') && !name.end_with?('ss')
|
|
118
|
+
name
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fail!(code, message, entry)
|
|
122
|
+
raise Error.single(code, message, source: entry['source'])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def add(node)
|
|
126
|
+
key = node.values_at('kind', 'id')
|
|
127
|
+
fail!('duplicate_id', "Identidade gerada colide com outra declaração: #{key.join(' ')}.", node) if @nodes.key?(key)
|
|
128
|
+
@nodes[key] = node
|
|
129
|
+
node
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def base(entry, kind = entry['kind'], id = entry['id'], generated: false)
|
|
133
|
+
node = { 'kind' => kind, 'id' => id, 'source' => Data.copy(entry['source']) }
|
|
134
|
+
node['domain'] = Data.reference('domain', entry['domain']) unless kind == 'domain'
|
|
135
|
+
node['generated_from'] = Data.reference(entry['kind'], entry['id']) if generated
|
|
136
|
+
node
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def qualify(value, domain)
|
|
140
|
+
value.include?('.') ? value : "#{domain}.#{value}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def entity_id(value, domain, entry)
|
|
144
|
+
id = qualify(value, domain)
|
|
145
|
+
unless raw('entity').any? { |entity| entity['id'] == id }
|
|
146
|
+
fail!('reference_missing', "Declare entity #{id} ou use referência qualificada ao domínio correto.", entry)
|
|
147
|
+
end
|
|
148
|
+
id
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def application_tenant(value)
|
|
152
|
+
return nil unless value
|
|
153
|
+
candidates = raw('entity').select { |entry| entry['id'] == value || (!value.include?('.') && entry['id'].split('.').last == value) }
|
|
154
|
+
raise Error.single('tenant_reference', 'Tenant deve identificar uma única entidade declarada; use domínio.nome.') unless candidates.size == 1
|
|
155
|
+
target = candidates.first['id']
|
|
156
|
+
{ 'entity' => Data.reference('entity', target), 'field' => "#{target.split('.').last}_id" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def domains
|
|
160
|
+
declared = raw('domain').to_h { |entry| [entry['id'], entry] }
|
|
161
|
+
@raw.filter_map { |entry| entry['domain'] }.uniq.each do |name|
|
|
162
|
+
declared[name] ||= { 'kind' => 'domain', 'id' => name, 'source' => @raw.find { |entry| entry['domain'] == name }['source'], 'depends_on' => nil }
|
|
163
|
+
end
|
|
164
|
+
declared.each_value do |entry|
|
|
165
|
+
add(base(entry).merge('depends_on' => entry['depends_on']&.map { |name| Data.reference('domain', name) }))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def field(entry, value)
|
|
170
|
+
result = Data.copy(value)
|
|
171
|
+
if result.dig('type', 'name') == 'money'
|
|
172
|
+
exponent = Currency.exponent(@currency)
|
|
173
|
+
unless exponent
|
|
174
|
+
raise Error.single('money_currency', "A moeda #{@currency} não tem expoente na tabela ISO 4217 distribuída; use uma moeda com unidade mínima definida.", pointer: '/app/currency', source: @registry.application['source'])
|
|
175
|
+
end
|
|
176
|
+
result['type']['scale'] = exponent unless result['type'].key?('scale')
|
|
177
|
+
end
|
|
178
|
+
if result.dig('type', 'entity').is_a?(String)
|
|
179
|
+
result['type']['entity'] = Data.reference('entity', entity_id(result['type']['entity'], entry['domain'], entry))
|
|
180
|
+
end
|
|
181
|
+
result['set_by'] = result['set_by'].map { |name| Data.reference('command', qualify(name, entry['domain'])) } if result['set_by']
|
|
182
|
+
result.fetch('guarded', []).each { |guard| guard['commands'] = guard['commands'].map { |name| Data.reference('command', qualify(name, entry['domain'])) } }
|
|
183
|
+
result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def id_field(name, target, required: true)
|
|
187
|
+
{ 'name' => name, 'type' => { 'name' => 'id', 'entity' => Data.reference('entity', target) }, 'required' => required, 'nullable' => false, 'privacy' => { 'personal' => false } }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def entity(entry)
|
|
191
|
+
options = entry['options']
|
|
192
|
+
tenant = if entry['id'] == @application_tenant&.dig('entity', 'id') || entry['tenant'] == false
|
|
193
|
+
nil
|
|
194
|
+
elsif entry.key?('tenant')
|
|
195
|
+
target = entry['tenant']
|
|
196
|
+
expected = @application_tenant&.dig('entity', 'id')
|
|
197
|
+
unless expected && [expected, expected.split('.').last].include?(target)
|
|
198
|
+
fail!('tenant_mismatch', 'Entidade deve usar o tenant da aplicação.', entry)
|
|
199
|
+
end
|
|
200
|
+
Data.copy(@application_tenant)
|
|
201
|
+
else
|
|
202
|
+
Data.copy(@application_tenant)
|
|
203
|
+
end
|
|
204
|
+
fields = entry['fields'].map { |value| field(entry, value) }
|
|
205
|
+
reserved = timestamps? ? %w[id created_at] : ['id']
|
|
206
|
+
if fields.any? { |value| reserved.include?(value['name']) }
|
|
207
|
+
fail!('reserved_field', "#{reserved.join(' e ')} são gerados pelo núcleo; não os declare na entidade.", entry)
|
|
208
|
+
end
|
|
209
|
+
fields << id_field('id', entry['id'])
|
|
210
|
+
if timestamps?
|
|
211
|
+
fields << { 'name' => 'created_at', 'type' => { 'name' => 'timestamp' }, 'required' => true,
|
|
212
|
+
'nullable' => false, 'privacy' => { 'personal' => false },
|
|
213
|
+
'generate' => { 'kind' => 'timestamp', 'on' => 'create' } }
|
|
214
|
+
end
|
|
215
|
+
fields << id_field(tenant['field'], tenant.dig('entity', 'id')) if tenant && !fields.any? { |value| value['name'] == tenant['field'] }
|
|
216
|
+
indexes = entry['indexes'].map.with_index do |index, i|
|
|
217
|
+
index.merge('name' => "#{plural(entry['id'].split('.').last)}_idx_#{i + 1}")
|
|
218
|
+
end
|
|
219
|
+
fields.select { |value| value.dig('constraints', 'unique') }.each do |value|
|
|
220
|
+
names = [tenant && tenant['field'], value['name']].compact.uniq
|
|
221
|
+
indexes << { 'name' => "#{plural(entry['id'].split('.').last)}_#{value['name']}_unique", 'fields' => names, 'unique' => true }
|
|
222
|
+
end
|
|
223
|
+
if tenant
|
|
224
|
+
indexes << { 'name' => "#{plural(entry['id'].split('.').last)}_tenant_id_unique", 'fields' => [tenant['field'], 'id'], 'unique' => true }
|
|
225
|
+
end
|
|
226
|
+
node = base(entry).merge(
|
|
227
|
+
'storage' => { 'schema' => 'public', 'table' => options.fetch(:table, plural(entry['id'].split('.').last)).to_s, 'primary_key' => ['id'] },
|
|
228
|
+
'tenant' => tenant, 'fields' => fields, 'relations' => [], 'indexes' => indexes,
|
|
229
|
+
'history' => entry['history'], 'transitions' => Data.copy(entry['transitions'])
|
|
230
|
+
)
|
|
231
|
+
node['previous_ids'] = Array(options[:was]).map { |name| qualify(name.to_s, entry['domain']) } if options.key?(:was)
|
|
232
|
+
node['generated_from'] = Data.copy(entry['generated_from']) if entry['generated_from']
|
|
233
|
+
@entities[entry['id']] = add(node)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def entity_artifacts(entry)
|
|
237
|
+
node = @entities.fetch(entry['id'])
|
|
238
|
+
entry['relations'].each do |relation|
|
|
239
|
+
target_id = entity_id(relation['target'], entry['domain'], entry)
|
|
240
|
+
target = @entities.fetch(target_id)
|
|
241
|
+
extra = {}
|
|
242
|
+
case relation['kind']
|
|
243
|
+
when 'has_many'
|
|
244
|
+
local, remote, same_tenant = inverse_keys(entry, relation, target_id, target)
|
|
245
|
+
when 'many_to_many'
|
|
246
|
+
local, remote, same_tenant, extra = junction_keys(entry, relation, target_id)
|
|
247
|
+
else
|
|
248
|
+
local, remote = foreign_keys(node, relation, target, entry)
|
|
249
|
+
same_tenant = relation['same_tenant']
|
|
250
|
+
end
|
|
251
|
+
node['relations'] << relation.slice('name', 'kind').merge('same_tenant' => same_tenant, 'target' => Data.reference('entity', target_id), 'local_fields' => local, 'target_fields' => remote).merge(extra)
|
|
252
|
+
end
|
|
253
|
+
check_memberships(entry, node) if entry['memberships']
|
|
254
|
+
repository = entry['options'].fetch(:repository, plural(entry['id'].split('.').last)).to_s
|
|
255
|
+
%w[read read_write].each do |access|
|
|
256
|
+
suffix = access == 'read' ? '_read' : ''
|
|
257
|
+
add(base(entry, 'capability', "#{entry['domain']}.#{repository}#{suffix}", generated: true).merge(
|
|
258
|
+
'adapter' => 'core.repository', 'retry_safe' => true, 'simulation' => 'memory',
|
|
259
|
+
'config' => { 'type' => 'repository', 'entity' => Data.reference('entity', entry['id']), 'access' => access }
|
|
260
|
+
))
|
|
261
|
+
end
|
|
262
|
+
contract = add(base(entry, 'contract', "#{entry['id']}.record", generated: true).merge('fields' => output_fields(node['fields'])))
|
|
263
|
+
@entities[entry['id']]['_contract'] = contract['id']
|
|
264
|
+
crud(entry) if entry['crud']
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def foreign_keys(owner, relation, target, entry)
|
|
268
|
+
local = ["#{relation['name']}_id"]
|
|
269
|
+
remote = ['id']
|
|
270
|
+
if relation['same_tenant']
|
|
271
|
+
tenant = owner['tenant']
|
|
272
|
+
fail!('same_tenant', 'same_tenant exige duas entidades com o mesmo tenant.', entry) unless tenant && target['tenant'] == tenant
|
|
273
|
+
local.unshift(tenant['field'])
|
|
274
|
+
remote.unshift(tenant['field'])
|
|
275
|
+
end
|
|
276
|
+
[local, remote]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# A has_many only mirrors a belongs_to of the target: both sides must share
|
|
280
|
+
# one key, so the inverse is derived from it instead of declared twice.
|
|
281
|
+
def inverse_keys(entry, relation, target_id, target)
|
|
282
|
+
target_entry = raw('entity').find { |item| item['id'] == target_id }
|
|
283
|
+
inverse = target_entry['relations'].find do |item|
|
|
284
|
+
item['kind'] == 'belongs_to' && item['name'] == relation['by'] && entity_id(item['target'], target_entry['domain'], target_entry) == entry['id']
|
|
285
|
+
end
|
|
286
|
+
unless inverse
|
|
287
|
+
fail!('relation_inverse', "has_many #{relation['name']} exige belongs_to :#{relation['by']} em #{target_id} apontando para #{entry['id']}.", entry)
|
|
288
|
+
end
|
|
289
|
+
local, remote = foreign_keys(target, inverse, @entities.fetch(entry['id']), target_entry)
|
|
290
|
+
[remote, local, inverse['same_tenant']]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A many_to_many goes through a junction entity declared by the application.
|
|
294
|
+
# Its local and target fields are the keys of the two sides that the junction
|
|
295
|
+
# references; which junction columns hold them comes from its belongs_to pair.
|
|
296
|
+
def junction_keys(entry, relation, target_id)
|
|
297
|
+
through_id = entity_id(relation['through'], entry['domain'], entry)
|
|
298
|
+
junction = raw('entity').find { |item| item['id'] == through_id }
|
|
299
|
+
links = lambda do |wanted|
|
|
300
|
+
junction['relations'].select { |item| item['kind'] == 'belongs_to' && entity_id(item['target'], junction['domain'], junction) == wanted }
|
|
301
|
+
end
|
|
302
|
+
owner_link, target_link = links.call(entry['id']), links.call(target_id)
|
|
303
|
+
unless owner_link.size == 1 && target_link.size == 1 && !owner_link.first.equal?(target_link.first)
|
|
304
|
+
fail!('relation_through', "A junção #{through_id} exige exatamente um belongs_to para #{entry['id']} e um para #{target_id}; auto-relações não são suportadas.", entry)
|
|
305
|
+
end
|
|
306
|
+
if owner_link.first['same_tenant'] != target_link.first['same_tenant']
|
|
307
|
+
fail!('relation_through', "Os dois belongs_to da junção #{through_id} devem usar o mesmo same_tenant.", entry)
|
|
308
|
+
end
|
|
309
|
+
junction_node = @entities.fetch(through_id)
|
|
310
|
+
_, local = foreign_keys(junction_node, owner_link.first, @entities.fetch(entry['id']), junction)
|
|
311
|
+
_, remote = foreign_keys(junction_node, target_link.first, @entities.fetch(target_id), junction)
|
|
312
|
+
included = relation['include'].uniq.map do |name|
|
|
313
|
+
own = entry['relations'].find { |item| item['kind'] == 'belongs_to' && item['name'] == name }
|
|
314
|
+
unless own && entity_id(own['target'], entry['domain'], entry) == target_id
|
|
315
|
+
fail!('relation_include', "include: :#{name} deve ser um belongs_to de #{entry['id']} para #{target_id}.", entry)
|
|
316
|
+
end
|
|
317
|
+
name
|
|
318
|
+
end
|
|
319
|
+
extra = { 'through' => Data.reference('entity', through_id) }
|
|
320
|
+
extra['include_on_create'] = included unless included.empty?
|
|
321
|
+
[local, remote, owner_link.first['same_tenant'], extra]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The identity of a scope is the entity its `of` relation points to, already
|
|
325
|
+
# qualified by domain; the relation name alone is not unique across domains.
|
|
326
|
+
def membership_scope_id(entry)
|
|
327
|
+
relation = entry['relations'].find { |item| item['kind'] == 'belongs_to' && item['name'] == entry.dig('memberships', 'of') }
|
|
328
|
+
relation && entity_id(relation['target'], entry['domain'], entry)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def check_memberships(entry, node)
|
|
332
|
+
config = entry['memberships']
|
|
333
|
+
links = node['relations'].select { |relation| relation['kind'] == 'belongs_to' && [config['of'], config['actor']].include?(relation['name']) }
|
|
334
|
+
unless links.map { |relation| relation['name'] }.uniq.size == 2
|
|
335
|
+
fail!('memberships_relation', "memberships exige belongs_to :#{config['of']} e belongs_to :#{config['actor']}, distintos, na própria entidade.", entry)
|
|
336
|
+
end
|
|
337
|
+
scope_id = membership_scope_id(entry)
|
|
338
|
+
duplicated = raw('entity').any? { |item| !item.equal?(entry) && item.dig('memberships', 'of') == config['of'] && membership_scope_id(item) == scope_id }
|
|
339
|
+
fail!('memberships_ambiguous', "Mais de uma entidade declara memberships of: :#{config['of']} para #{scope_id}.", entry) if duplicated
|
|
340
|
+
role = node['fields'].find { |value| value['name'] == config['role'] }
|
|
341
|
+
unless role && role.dig('type', 'name') == 'enum' && !role['nullable']
|
|
342
|
+
fail!('memberships_role', "memberships exige o enum não nulo #{config['role']} com os papéis do escopo.", entry)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def output_fields(fields)
|
|
347
|
+
fields.map { |value| Data.copy(value.slice('name', 'type', 'nullable', 'privacy')).merge('required' => true) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def plural(name)
|
|
351
|
+
return "#{name[0...-1]}ies" if name.match?(/[^aeiou]y\z/)
|
|
352
|
+
return "#{name}es" if name.match?(/(?:s|x|ch|sh)\z/)
|
|
353
|
+
"#{name}s"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def crud(entry)
|
|
357
|
+
entity = @entities.fetch(entry['id'])
|
|
358
|
+
local = entry['id'].split('.').last
|
|
359
|
+
repository = entry['options'].fetch(:repository, plural(local)).to_s
|
|
360
|
+
writable = entity['fields'].reject { |value| value['name'] == 'id' || value['name'] == entity.dig('tenant', 'field') || value['set_by'] || value.dig('generate', 'kind') == 'timestamp' }
|
|
361
|
+
ACTIONS.each do |action|
|
|
362
|
+
id = "#{entry['domain']}.#{action}_#{local}"
|
|
363
|
+
input_fields = case action
|
|
364
|
+
when 'create' then Data.copy(writable)
|
|
365
|
+
when 'update' then [id_field('id', entry['id']), *Data.copy(writable).map { |value| value.merge('required' => false) }]
|
|
366
|
+
else [id_field('id', entry['id'])]
|
|
367
|
+
end
|
|
368
|
+
input_fields.each do |value|
|
|
369
|
+
if value['guarded']
|
|
370
|
+
protected_values = value['guarded'].map { |guard| guard['value'] }
|
|
371
|
+
value['type']['values'] -= protected_values
|
|
372
|
+
value['type']['groups']&.each_value { |values| values.reject! { |item| protected_values.include?(item) } }
|
|
373
|
+
value['type']['groups']&.delete_if { |_, values| values.empty? }
|
|
374
|
+
fail!('crud_enum', 'Enum do CRUD não tem valores graváveis.', entry) if value['type']['values'].empty?
|
|
375
|
+
end
|
|
376
|
+
value.delete('guarded')
|
|
377
|
+
value.delete('set_by')
|
|
378
|
+
value.delete('previous_names')
|
|
379
|
+
value.delete('default') if action == 'update'
|
|
380
|
+
end
|
|
381
|
+
input = add(base(entry, 'contract', "#{id}.input", generated: true).merge('fields' => input_fields))
|
|
382
|
+
kind = action == 'read' ? 'query' : 'command'
|
|
383
|
+
authorize = { 'action' => action, 'entity' => Data.reference('entity', entry['id']) }
|
|
384
|
+
authorize['record'] = 'id' unless action == 'create'
|
|
385
|
+
operation = base(entry, kind, id, generated: true).merge(
|
|
386
|
+
'input' => Data.reference('contract', input['id']), 'output' => Data.reference('contract', entity.fetch('_contract')),
|
|
387
|
+
'authorize' => authorize, 'uses' => [Data.reference('capability', "#{entry['domain']}.#{repository}#{kind == 'query' ? '_read' : ''}")],
|
|
388
|
+
'implementation' => { 'kind' => 'builtin', 'name' => "crud.#{action}" }
|
|
389
|
+
)
|
|
390
|
+
operation['emits'] = [] if kind == 'command'
|
|
391
|
+
add(operation)
|
|
392
|
+
method = { 'create' => 'POST', 'read' => 'GET', 'update' => 'PATCH', 'delete' => 'DELETE' }.fetch(action)
|
|
393
|
+
path = "/#{entity.dig('storage', 'table')}#{action == 'create' ? '' : '/:id'}"
|
|
394
|
+
route(entry, operation, { 'method' => method, 'path' => path }, 'optional', "#{id}.http")
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def role(entry)
|
|
399
|
+
add(base(entry).merge(entry.slice('anonymous')))
|
|
400
|
+
entry['permissions'].each_with_index do |permission, i|
|
|
401
|
+
target_id = entity_id(permission['entity'], entry['domain'], entry)
|
|
402
|
+
target = @entities.fetch(target_id)
|
|
403
|
+
actions = permission['actions'].flat_map { |action| action == 'manage' ? ACTIONS : [action] }
|
|
404
|
+
reads = [Data.reference('entity', target_id)]
|
|
405
|
+
predicate = where(permission['where'], target, reads, entry)
|
|
406
|
+
scope = permission['scope'] || @raw.find { |raw| raw['kind'] == 'entity' && raw['id'] == target_id }&.dig('crud', 'scope')
|
|
407
|
+
if scope
|
|
408
|
+
holder = membership_for(scope, target, entry)
|
|
409
|
+
scoped = if holder
|
|
410
|
+
membership_scope(entry, target, holder, reads)
|
|
411
|
+
elsif %w[author owner seller].include?(scope) && target['fields'].any? { |value| value['name'] == "#{scope}_id" }
|
|
412
|
+
{ 'op' => 'eq', 'left' => { 'kind' => 'field', 'path' => ["#{scope}_id"] }, 'right' => { 'kind' => 'actor', 'path' => ['id'] } }
|
|
413
|
+
else
|
|
414
|
+
declared = raw('entity').select { |item| item.dig('memberships', 'of') == scope }.map { |item| membership_scope_id(item) }
|
|
415
|
+
unless declared.empty?
|
|
416
|
+
fail!('scope_unsupported', "#{target['id']} não declara belongs_to :#{scope} para #{declared.join(', ')}.", entry)
|
|
417
|
+
end
|
|
418
|
+
fail!('scope_unsupported', "Escopo #{scope} desconhecido: use author/owner/seller por actor.id ou declare memberships of: :#{scope} em uma entidade de vínculo.", entry)
|
|
419
|
+
end
|
|
420
|
+
predicate = predicate['op'] == 'true' ? scoped : { 'op' => 'and', 'terms' => [predicate, scoped] }
|
|
421
|
+
end
|
|
422
|
+
add(base(entry, 'policy', "#{entry['id']}.permission_#{i + 1}", generated: true).merge(
|
|
423
|
+
'role' => Data.reference('role', entry['id']), 'entity' => Data.reference('entity', target_id),
|
|
424
|
+
'actions' => actions.uniq, 'fields' => permission['fields'], 'predicate' => predicate, 'reads' => reads.uniq
|
|
425
|
+
))
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# A scope name may be declared by several memberships (one per scope entity,
|
|
430
|
+
# e.g. one in each domain). The one that applies is the one whose scope entity is
|
|
431
|
+
# the authorized entity itself or the entity its `belongs_to :<scope>` points to.
|
|
432
|
+
def membership_for(scope, target, entry)
|
|
433
|
+
applicable = raw('entity').select do |item|
|
|
434
|
+
next false unless item.dig('memberships', 'of') == scope
|
|
435
|
+
|
|
436
|
+
scope_id = membership_scope_id(item)
|
|
437
|
+
target['id'] == scope_id || target['relations'].any? { |relation| relation['kind'] == 'belongs_to' && relation['name'] == scope && relation.dig('target', 'id') == scope_id }
|
|
438
|
+
end
|
|
439
|
+
if applicable.size > 1
|
|
440
|
+
fail!('memberships_ambiguous', "Mais de um vínculo (#{applicable.map { |item| item['id'] }.join(', ')}) se aplica a #{target['id']} no escopo #{scope}.", entry)
|
|
441
|
+
end
|
|
442
|
+
applicable.first
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The actor reaches the scope through its memberships: the record (or the
|
|
446
|
+
# scope entity itself) must have a membership of the actor with this role.
|
|
447
|
+
def membership_scope(entry, target, declaration, reads)
|
|
448
|
+
fail!('scope_anonymous', 'Papel anônimo não tem vínculo; declare o escopo em um papel autenticado.', entry) if entry['anonymous']
|
|
449
|
+
config = declaration['memberships']
|
|
450
|
+
holder = @entities.fetch(declaration['id'])
|
|
451
|
+
role_name = entry['id'].split('.').last
|
|
452
|
+
role_values = holder['fields'].find { |value| value['name'] == config['role'] }.dig('type', 'values')
|
|
453
|
+
unless role_values.include?(role_name)
|
|
454
|
+
fail!('memberships_role', "O papel #{role_name} não consta em #{holder['id']}.#{config['role']} (#{role_values.join(', ')}).", entry)
|
|
455
|
+
end
|
|
456
|
+
link = holder['relations'].find { |relation| relation['kind'] == 'belongs_to' && relation['name'] == config['of'] }
|
|
457
|
+
scope_id = link.dig('target', 'id')
|
|
458
|
+
inverses = @entities.fetch(scope_id)['relations'].select do |relation|
|
|
459
|
+
relation['kind'] == 'has_many' && relation.dig('target', 'id') == holder['id'] && relation['target_fields'] == link['local_fields']
|
|
460
|
+
end
|
|
461
|
+
unless inverses.size == 1
|
|
462
|
+
fail!('scope_inverse', "Declare exatamente um has_many para #{holder['id']} (by: :#{config['of']}) em #{scope_id}.", entry)
|
|
463
|
+
end
|
|
464
|
+
path = [inverses.first['name']]
|
|
465
|
+
unless target['id'] == scope_id
|
|
466
|
+
hop = target['relations'].find { |relation| relation['kind'] == 'belongs_to' && relation['name'] == config['of'] && relation.dig('target', 'id') == scope_id }
|
|
467
|
+
fail!('scope_unsupported', "#{target['id']} não declara belongs_to :#{config['of']} para #{scope_id}.", entry) unless hop
|
|
468
|
+
path.unshift(hop['name'])
|
|
469
|
+
end
|
|
470
|
+
reads.push(Data.reference('entity', scope_id), Data.reference('entity', holder['id']))
|
|
471
|
+
{
|
|
472
|
+
'op' => 'exists', 'relation' => path,
|
|
473
|
+
'where' => { 'op' => 'and', 'terms' => [
|
|
474
|
+
{ 'op' => 'eq', 'left' => { 'kind' => 'field', 'path' => ["#{config['actor']}_id"] }, 'right' => { 'kind' => 'actor', 'path' => ['id'] } },
|
|
475
|
+
{ 'op' => 'eq', 'left' => { 'kind' => 'field', 'path' => [config['role']] }, 'right' => { 'kind' => 'literal', 'value' => role_name } }
|
|
476
|
+
] }
|
|
477
|
+
}
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def where(values, target, reads, entry)
|
|
481
|
+
terms = values.map do |key, value|
|
|
482
|
+
name = key.to_s
|
|
483
|
+
fields = target['fields'].map { |field| field['name'] }
|
|
484
|
+
selector = fields.include?(name) ? name : "#{name}_id"
|
|
485
|
+
if value.is_a?(Hash) || (!fields.include?(selector) && value == :current_actor)
|
|
486
|
+
relation = target['relations'].find { |item| item['name'] == name }
|
|
487
|
+
fail!('predicate_relation', "Relação #{name} não declarada.", entry) unless relation
|
|
488
|
+
reads << relation['target']
|
|
489
|
+
reads << relation['through'] if relation['through']
|
|
490
|
+
destination = @entities.fetch(relation.dig('target', 'id'))
|
|
491
|
+
nested = value == :current_actor ? { id: :current_actor } : value
|
|
492
|
+
{ 'op' => 'exists', 'relation' => [name], 'where' => where(nested, destination, reads, entry) }
|
|
493
|
+
else
|
|
494
|
+
right = value == :current_actor ? { 'kind' => 'actor', 'path' => ['id'] } : { 'kind' => 'literal', 'value' => value.is_a?(Symbol) ? value.to_s : value }
|
|
495
|
+
{ 'op' => 'eq', 'left' => { 'kind' => 'field', 'path' => [selector] }, 'right' => right }
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
return { 'op' => 'true' } if terms.empty?
|
|
499
|
+
terms.size == 1 ? terms.first : { 'op' => 'and', 'terms' => terms }
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def event(entry)
|
|
503
|
+
target_id = entity_id(entry['from'], entry['domain'], entry)
|
|
504
|
+
target = @entities.fetch(target_id)
|
|
505
|
+
selected = project_fields(target, entry['fields'], entry)
|
|
506
|
+
payload = add(base(entry, 'contract', "#{entry['id']}.payload", generated: true).merge('fields' => output_fields(selected)))
|
|
507
|
+
add(base(entry).merge('payload' => Data.reference('contract', payload['id']), 'version' => entry['version'], 'visibility' => entry['visibility'], 'consumer_required' => entry['consumer_required']))
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def project_fields(target, names, entry)
|
|
511
|
+
names.map do |name|
|
|
512
|
+
target['fields'].find { |field| field['name'] == name } || fail!('field_missing', "Campo #{name} não declarado em #{target['id']}.", entry)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def capability(name, entry)
|
|
517
|
+
id = qualify(name, entry['domain'])
|
|
518
|
+
if !@nodes.key?(['capability', id]) && %w[clock uuid random].include?(name)
|
|
519
|
+
add(base(entry, 'capability', id, generated: true).merge('adapter' => "core.#{name}", 'retry_safe' => true, 'simulation' => 'seeded', 'config' => { 'type' => 'deterministic' }))
|
|
520
|
+
end
|
|
521
|
+
Capabilities.reference(name, domain: entry['domain'], operation_kind: entry['kind'], nodes: @nodes, source: entry['source'])
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def operation(entry)
|
|
525
|
+
kind = entry['kind']
|
|
526
|
+
forbidden = kind == 'handler' ? %w[fields emits exposures authorize returns live] : %w[on calls idempotent_by]
|
|
527
|
+
forbidden += ['emits'] if kind == 'query'
|
|
528
|
+
forbidden += ['live'] if kind == 'command'
|
|
529
|
+
forbidden.each do |key|
|
|
530
|
+
value = entry[key]
|
|
531
|
+
fail!('unsupported_declaration', "#{key} não é válido em #{kind} neste protótipo.", entry) if value && (!value.respond_to?(:empty?) || !value.empty?)
|
|
532
|
+
end
|
|
533
|
+
block = @registry.bindings.fetch("#{entry['id']}#call")
|
|
534
|
+
report = @analysis.inspect_block(block, entry['uses'], declaration_source: entry['source'], declaration_kind: kind)
|
|
535
|
+
unless report['forbidden'].empty? || entry['unsafe_effect']
|
|
536
|
+
fail!('direct_effect', "Efeito direto #{report['forbidden'].join(', ')}; use capacidades ou declare unsafe_effect com motivo.", entry)
|
|
537
|
+
end
|
|
538
|
+
node = base(entry).merge('uses' => entry['uses'].map { |name| capability(name, entry) },
|
|
539
|
+
'implementation' => { 'kind' => 'ruby', 'ref' => "#{entry['id']}#call", 'path' => report.dig('source', 'path'), 'digest' => @manifest.digest_for(report.dig('source', 'path')), 'analysis' => report['analysis'] })
|
|
540
|
+
node['unsafe_effect'] = entry['unsafe_effect'] if entry['unsafe_effect']
|
|
541
|
+
if kind == 'handler'
|
|
542
|
+
fail!('handler_idempotency', 'Declare idempotent_by :event_id no handler.', entry) unless entry['idempotent_by']
|
|
543
|
+
node.merge!('on' => entry['on'].map { |name| Data.reference('event', qualify(name, entry['domain'])) }, 'calls' => entry['calls'].map { |name| Data.reference('command', qualify(name, entry['domain'])) }, 'idempotent_by' => entry['idempotent_by'])
|
|
544
|
+
else
|
|
545
|
+
fail!('authorize_missing', 'Declare authorize no comando ou consulta.', entry) unless entry['authorize']
|
|
546
|
+
fail!('output_contract', 'Declare returns :entidade; inferência de retorno Ruby arbitrário não é implementada.', entry) unless entry['returns']
|
|
547
|
+
authorization = entry['authorize'].merge('entity' => Data.reference('entity', entity_id(entry.dig('authorize', 'entity'), entry['domain'], entry)))
|
|
548
|
+
input_fields = entry['fields'].map { |value| field(entry, value) }
|
|
549
|
+
record = input_fields.find { |value| value['name'] == authorization['record'] }
|
|
550
|
+
record['type']['entity'] ||= authorization['entity'] if record && record.dig('type', 'name') == 'id'
|
|
551
|
+
input = add(base(entry, 'contract', "#{entry['id']}.input", generated: true).merge('fields' => input_fields))
|
|
552
|
+
node.merge!('input' => Data.reference('contract', input['id']), 'output' => operation_output(entry), 'authorize' => authorization)
|
|
553
|
+
node['emits'] = emissions(entry, authorization) if kind == 'command'
|
|
554
|
+
end
|
|
555
|
+
live(entry, node) if kind == 'query' && entry.dig('live', 'enabled')
|
|
556
|
+
check_writes(entry, node, report)
|
|
557
|
+
add(node)
|
|
558
|
+
counts = Hash.new(0)
|
|
559
|
+
entry['exposures'].each do |exposure|
|
|
560
|
+
surface = exposure['surface']
|
|
561
|
+
config = if %w[http webhook].include?(surface)
|
|
562
|
+
method, path = exposure['value'].split(' ', 2)
|
|
563
|
+
fail!('route_format', "expose #{surface} exige \"MÉTODO /caminho\".", entry.merge('source' => exposure['source'])) unless method && path
|
|
564
|
+
{ 'method' => method, 'path' => path }
|
|
565
|
+
else
|
|
566
|
+
{ 'name' => exposure['value'] }
|
|
567
|
+
end
|
|
568
|
+
counts[surface] += 1
|
|
569
|
+
route(entry, node, config, exposure['idempotency'], "#{entry['id']}.#{surface}_#{counts[surface]}", surface: surface)
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def live(entry, node)
|
|
574
|
+
fail!('ir_feature', 'Este consumidor só compila live na IR 0.3.', entry) unless @ir_version == Schema::VERSION
|
|
575
|
+
if LiveDependencies.outside_boundary?(node, nodes: @nodes)
|
|
576
|
+
fail!('live_boundary', 'Live aceita repositórios e capacidades determinísticas, sem unsafe_effect.', entry)
|
|
577
|
+
end
|
|
578
|
+
names = entry.dig('live', 'depends_on')
|
|
579
|
+
if names.nil? && node.dig('implementation', 'analysis', 'status') != 'complete'
|
|
580
|
+
fail!('live_dependencies', 'Análise incompleta exige depends_on explícito em live; declare também as leituras dos helpers.', entry)
|
|
581
|
+
end
|
|
582
|
+
declared = names&.map { |name| Data.reference('entity', entity_id(name, entry['domain'], entry)) }
|
|
583
|
+
if declared && declared.uniq.size != declared.size
|
|
584
|
+
fail!('live_dependencies', 'Cada entidade aparece uma única vez em depends_on de live.', entry)
|
|
585
|
+
end
|
|
586
|
+
required = LiveDependencies.required(node, nodes: @nodes, extra: declared || [])
|
|
587
|
+
missing = required - (declared || required)
|
|
588
|
+
unless missing.empty?
|
|
589
|
+
fail!('live_dependencies', "depends_on de live omite entidades conhecidas: #{missing.map { |reference| reference['id'] }.join(', ')}.", entry)
|
|
590
|
+
end
|
|
591
|
+
node['live'] = { 'mode' => entry.dig('live', 'mode'), 'depends_on' => (declared || required).sort_by { |reference| reference.values_at('kind', 'id') } }
|
|
592
|
+
@live = true
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def operation_output(entry)
|
|
596
|
+
declaration = entry['returns']
|
|
597
|
+
if declaration.is_a?(String)
|
|
598
|
+
target = @entities.fetch(entity_id(declaration, entry['domain'], entry))
|
|
599
|
+
return Data.reference('contract', target.fetch('_contract'))
|
|
600
|
+
end
|
|
601
|
+
fail!('output_shape', 'Retornos compostos e coleções são suportados somente em consultas.', entry) unless entry['kind'] == 'query'
|
|
602
|
+
fail!('ir_feature', 'Retornos estruturados exigem IR 0.3.', entry) unless @ir_version == Schema::VERSION
|
|
603
|
+
@structured_outputs = true
|
|
604
|
+
output = base(entry, 'contract', "#{entry['id']}.output", generated: true)
|
|
605
|
+
if declaration['collection']
|
|
606
|
+
entity = @entities.fetch(entity_id(declaration['entity'], entry['domain'], entry))
|
|
607
|
+
output.merge!('collection' => true, 'fields' => Data.copy(@nodes.fetch(['contract', entity.fetch('_contract')])['fields']))
|
|
608
|
+
else
|
|
609
|
+
output['fields'] = declaration['fields'].map do |value|
|
|
610
|
+
result = field(entry, value)
|
|
611
|
+
next result unless %w[record collection].include?(value.dig('type', 'name'))
|
|
612
|
+
entity = @entities.fetch(entity_id(value.dig('type', 'entity'), entry['domain'], entry))
|
|
613
|
+
contract_id = entity.fetch('_contract')
|
|
614
|
+
result['type'] = { 'name' => value.dig('type', 'name'), 'contract' => Data.reference('contract', contract_id) }
|
|
615
|
+
personal = @nodes.fetch(['contract', contract_id])['fields'].map { |child| child['privacy'] }.select { |privacy| privacy['personal'] }
|
|
616
|
+
result['privacy'] = if personal.empty?
|
|
617
|
+
{ 'personal' => false }
|
|
618
|
+
else
|
|
619
|
+
{ 'personal' => true, 'classification' => personal.any? { |privacy| privacy['classification'] == 'sensitive' } ? 'sensitive' : 'personal' }
|
|
620
|
+
end
|
|
621
|
+
result
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
Data.reference('contract', add(output)['id'])
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def emissions(entry, authorization)
|
|
628
|
+
entry['emits'].map do |emission|
|
|
629
|
+
target_id = emission['from'] ? entity_id(emission['from'], entry['domain'], entry) : authorization.dig('entity', 'id')
|
|
630
|
+
event_id = qualify(emission['event'], entry['domain'])
|
|
631
|
+
unless @nodes.key?(['event', event_id])
|
|
632
|
+
event({ 'kind' => 'event', 'id' => event_id, 'domain' => entry['domain'], 'source' => entry['source'], 'from' => target_id, 'fields' => emission['fields'], 'version' => 1, 'visibility' => 'internal', 'consumer_required' => false })
|
|
633
|
+
end
|
|
634
|
+
{ 'event' => Data.reference('event', event_id), 'from' => Data.reference('entity', target_id), 'fields' => emission['fields'] }
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def check_writes(entry, node, report)
|
|
639
|
+
aliases = entry['uses'].zip(node['uses']).to_h { |name, reference| [name.split('.').last, @nodes[[reference['kind'], reference['id']]]] }
|
|
640
|
+
report['writes'].each do |write|
|
|
641
|
+
capability = aliases[write['receiver']]
|
|
642
|
+
next unless capability && capability.dig('config', 'type') == 'repository'
|
|
643
|
+
unless entry['kind'] == 'command' && capability.dig('config', 'access') == 'read_write'
|
|
644
|
+
fail!('write_boundary', 'Gravações exigem comando e repositório com acesso read_write; handlers só gravam chamando comandos.', entry)
|
|
645
|
+
end
|
|
646
|
+
target = @entities.fetch(capability.dig('config', 'entity', 'id'))
|
|
647
|
+
write['fields'].each do |value|
|
|
648
|
+
field = target['fields'].find { |item| item['name'] == value['name'] }
|
|
649
|
+
fail!('field_missing', "Gravação literal em campo inexistente: #{value['name']}.", entry) unless field
|
|
650
|
+
if field.dig('generate', 'kind') == 'timestamp'
|
|
651
|
+
fail!('generated_field_write', "#{value['name']} é gerado pelo relógio do repositório; não o atribua explicitamente.", entry)
|
|
652
|
+
end
|
|
653
|
+
guard = field.fetch('guarded', []).find { |item| value['known'] && item['value'] == value['value'] }
|
|
654
|
+
owners = field['set_by'] || guard&.fetch('commands')
|
|
655
|
+
if owners && !owners.include?(Data.reference('command', entry['id']))
|
|
656
|
+
fail!('transition_owner', "Campo #{value['name']} exige o comando dono declarado em set_by/guarded.", entry)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def route(entry, target, config, idempotency, id, surface: 'http')
|
|
663
|
+
add(base(entry, 'route', id, generated: true).merge('surface' => surface, 'target' => Data.reference(target['kind'], target['id']), 'config' => config, 'idempotency' => idempotency))
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def sort_sets(document)
|
|
667
|
+
document['nodes'].each do |node|
|
|
668
|
+
node.delete('_contract')
|
|
669
|
+
%w[fields relations indexes].each do |key|
|
|
670
|
+
node[key].sort_by! { |value| value.is_a?(Hash) ? value['name'] : value } if node[key].is_a?(Array)
|
|
671
|
+
end
|
|
672
|
+
%w[uses on calls reads depends_on].each do |key|
|
|
673
|
+
node[key].sort_by! { |value| value.values_at('kind', 'id') } if node[key].is_a?(Array)
|
|
674
|
+
end
|
|
675
|
+
%w[previous_ids actions].each { |key| node[key]&.sort! }
|
|
676
|
+
node.fetch('emits', []).sort_by! { |emission| emission.dig('event', 'id') }
|
|
677
|
+
node.fetch('emits', []).each { |emission| emission['fields'].sort! }
|
|
678
|
+
Array(node['fields']).each do |field|
|
|
679
|
+
next unless field.is_a?(Hash)
|
|
680
|
+
field['previous_names']&.sort!
|
|
681
|
+
field['set_by']&.sort_by! { |value| value.values_at('kind', 'id') }
|
|
682
|
+
field['guarded']&.sort_by! { |guard| guard['value'] }
|
|
683
|
+
field.fetch('guarded', []).each { |guard| guard['commands'].sort_by! { |value| value.values_at('kind', 'id') } }
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|