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.
@@ -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