@softize/opus 15.0.1 → 15.2.0
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.
- package/CHANGELOG.md +33 -0
- package/README.md +3 -3
- package/bin/lib/check.mjs +134 -11
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +37 -0
- package/bin/lib/introspect.mjs +16 -4
- package/docs/adr/0004-page-content-state-is-composed.md +3 -0
- package/docs/adr/0010-page-header-owns-page-chrome.md +4 -0
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +70 -0
- package/docs/adr/0012-data-products-are-first-class-declarations.md +72 -0
- package/docs/data-products.md +66 -0
- package/docs/protocol.md +12 -0
- package/package.json +15 -14
- package/registry/skills/build-opus-ui/references/ui-patterns.md +12 -2
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +0 -0
- package/src/core/data-product.ts +121 -0
- package/src/core/domain.ts +56 -2
- package/src/core/index.ts +9 -0
- package/src/core/runtime.ts +27 -2
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/page.tsx +200 -18
- package/src/ui/components/patterns/surface-header.tsx +13 -4
- package/src/ui/components/primitives/chat.tsx +58 -14
- package/src/ui/docs/content/chat.md +6 -0
- package/src/ui/docs/content/page.md +99 -26
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +4 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Produtos de Dados
|
|
3
|
+
order: 7
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Produtos de Dados
|
|
7
|
+
|
|
8
|
+
Um Produto de Dados dá identidade governada ao conjunto que uma ou mais Actions disponibilizam.
|
|
9
|
+
Ele não é uma query, tabela ou view: essas são implementações possíveis. A fronteira de execução,
|
|
10
|
+
recorte e autorização continua sendo cada Action.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { defineDataProduct, defineDomain } from '@softize/opus'
|
|
14
|
+
|
|
15
|
+
export const salesLeads = defineDataProduct({
|
|
16
|
+
id: 'sales.leads',
|
|
17
|
+
version: 1,
|
|
18
|
+
label: 'Leads comerciais',
|
|
19
|
+
description: 'Leads e seus resultados comerciais.',
|
|
20
|
+
owner: 'Vendas',
|
|
21
|
+
grain: 'Um lead.',
|
|
22
|
+
classification: 'internal',
|
|
23
|
+
nature: 'real',
|
|
24
|
+
sources: [{ id: 'followize', label: 'Followize' }],
|
|
25
|
+
entities: ['Lead'],
|
|
26
|
+
access: {
|
|
27
|
+
contexts: ['sales'],
|
|
28
|
+
organizationalScopes: ['unit', 'team'],
|
|
29
|
+
},
|
|
30
|
+
interfaces: ['sale.list', 'sales.performance'],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const salesDomain = defineDomain({
|
|
34
|
+
name: 'sales',
|
|
35
|
+
entities: { Lead },
|
|
36
|
+
actions: { saleList, salesPerformance },
|
|
37
|
+
dataProducts: { salesLeads },
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`interfaces` e `entities` usam os nomes declarados, não nomes de arquivo ou tabela. O registro do
|
|
42
|
+
domínio e `opus check` detectam relações inexistentes. `sources` identifica sistemas ou conjuntos
|
|
43
|
+
upstream; uma Fonte não precisa ser uma Entity Opus.
|
|
44
|
+
|
|
45
|
+
## Acesso e segurança
|
|
46
|
+
|
|
47
|
+
`access` é documentação verificável do alcance esperado. Ele ajuda catálogo e revisão a perceber
|
|
48
|
+
que uma interface deveria respeitar, por exemplo, unidade e equipe. Não é RLS nem autorização
|
|
49
|
+
automática. Toda interface precisa aplicar seus próprios `requires`, `authorize` e recortes no
|
|
50
|
+
handler/repositório, inclusive quando for chamada por IA ou MCP.
|
|
51
|
+
|
|
52
|
+
## Projeções
|
|
53
|
+
|
|
54
|
+
`opus gen` publica os produtos no `.opus/manifest.json`. Actions expostas como tools carregam os
|
|
55
|
+
IDs em `metadata.dataProducts`; no MCP, a chave é
|
|
56
|
+
`_meta['com.softize.opus/data-products']`. Consumidores devem registrar apenas produtos associados
|
|
57
|
+
a chamadas concluídas com sucesso.
|
|
58
|
+
|
|
59
|
+
O Opus Lens usa o manifest para mostrar a linhagem declarada entre Fontes, entities, produtos e
|
|
60
|
+
Actions. Nenhuma dessas projeções é fonte autoritativa: corrija a declaração e gere novamente.
|
|
61
|
+
|
|
62
|
+
## Ciclo de vida
|
|
63
|
+
|
|
64
|
+
Incremente `version` quando a forma observável do produto mudar. Para substituí-lo, mantenha a
|
|
65
|
+
declaração anterior com `status: 'deprecated'` e `replacedBy` apontando para o novo ID durante a
|
|
66
|
+
migração. Produto ativo não declara `replacedBy`.
|
package/docs/protocol.md
CHANGED
|
@@ -2084,6 +2084,18 @@ export const crm = defineDomain({
|
|
|
2084
2084
|
|
|
2085
2085
|
---
|
|
2086
2086
|
|
|
2087
|
+
### 15.1 Produtos de Dados
|
|
2088
|
+
|
|
2089
|
+
`defineDataProduct` declara um conjunto governado consumível por pessoas, relatórios ou agentes.
|
|
2090
|
+
Ele liga Fontes e entities às Actions que formam sua interface, sem conter query, driver ou regra
|
|
2091
|
+
de autorização. O formato completo, o ciclo de vida e as projeções estão em
|
|
2092
|
+
[Produtos de Dados](data-products.md); a decisão arquitetural está na
|
|
2093
|
+
[ADR 0012](adr/0012-data-products-are-first-class-declarations.md).
|
|
2094
|
+
|
|
2095
|
+
A declaração de `access` é descritiva. O pipeline da Action permanece a única fronteira
|
|
2096
|
+
executável: `requires`, `authorize`, carregamentos e recortes continuam sendo aplicados em toda
|
|
2097
|
+
chamada, inclusive por IA e MCP.
|
|
2098
|
+
|
|
2087
2099
|
## 16. Glossário
|
|
2088
2100
|
|
|
2089
2101
|
- **Action** — unidade declarativa que flui pelo pipeline opus. Ver §2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "15.0
|
|
3
|
+
"version": "15.2.0",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -212,6 +212,17 @@
|
|
|
212
212
|
"bin": {
|
|
213
213
|
"opus": "bin/cli.mjs"
|
|
214
214
|
},
|
|
215
|
+
"scripts": {
|
|
216
|
+
"postinstall": "node ./bin/lib/postinstall.mjs",
|
|
217
|
+
"copy:check": "node ./bin/cli.mjs copy --check",
|
|
218
|
+
"typecheck": "tsc --noEmit",
|
|
219
|
+
"test": "vitest run",
|
|
220
|
+
"test:watch": "vitest",
|
|
221
|
+
"test:cov": "vitest run --coverage",
|
|
222
|
+
"registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
|
|
223
|
+
"release": "bash ./scripts/release.sh",
|
|
224
|
+
"release:local": "bash ./scripts/release.sh --local"
|
|
225
|
+
},
|
|
215
226
|
"dependencies": {
|
|
216
227
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
217
228
|
"@radix-ui/react-checkbox": "^1.1.3",
|
|
@@ -357,21 +368,11 @@
|
|
|
357
368
|
"vitest": "^2.1.0",
|
|
358
369
|
"zod": "^3.24.0"
|
|
359
370
|
},
|
|
371
|
+
"packageManager": "pnpm@9.0.0",
|
|
360
372
|
"repository": {
|
|
361
373
|
"type": "git",
|
|
362
374
|
"url": "git+https://github.com/softize-dev/opus.git",
|
|
363
375
|
"directory": "packages/opus"
|
|
364
376
|
},
|
|
365
|
-
"homepage": "https://opus.softize.com.br"
|
|
366
|
-
|
|
367
|
-
"postinstall": "node ./bin/lib/postinstall.mjs",
|
|
368
|
-
"copy:check": "node ./bin/cli.mjs copy --check",
|
|
369
|
-
"typecheck": "tsc --noEmit",
|
|
370
|
-
"test": "vitest run",
|
|
371
|
-
"test:watch": "vitest",
|
|
372
|
-
"test:cov": "vitest run --coverage",
|
|
373
|
-
"registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
|
|
374
|
-
"release": "bash ./scripts/release.sh",
|
|
375
|
-
"release:local": "bash ./scripts/release.sh --local"
|
|
376
|
-
}
|
|
377
|
-
}
|
|
377
|
+
"homepage": "https://opus.softize.com.br"
|
|
378
|
+
}
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
para o caso direto.
|
|
11
11
|
Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
|
|
12
12
|
real de largura; não reconstruir esse container em cada rota.
|
|
13
|
+
- Fora de `PageShell`, a composição explícita também pode trocar `PageHeader` por `PageIntro` quando
|
|
14
|
+
o conteúdo precisar somente de título, descrição e ações, sem retorno ou breadcrumb. `PageIntro`
|
|
15
|
+
dá mais presença ao título e não é uma abreviação visual de `PageHeader`.
|
|
13
16
|
- `PageHeader` é a única região de cabeçalho da página. O default acompanha o container;
|
|
14
17
|
`variant="bar"` apresenta a mesma anatomia como faixa compacta no topo. Em uma subpágina simples,
|
|
15
18
|
`PageBack` recebe o destino pai explícito: aparece acima do título no default e como icon-only com
|
|
@@ -17,10 +20,17 @@
|
|
|
17
20
|
`PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome paralelo para uma `Page`.
|
|
18
21
|
Ações com texto na barra usam `Button size="sm"`; ações somente com ícone usam `icon-sm`.
|
|
19
22
|
`PageActionsTarget` fica reservado a workspaces imersivos que já possuam chrome próprio.
|
|
23
|
+
- Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
|
|
24
|
+
O shell fornece `navigation`; a `Page` descendente continua declarando `title`, `description` e
|
|
25
|
+
`actions`. O Opus mantém a barra de `3rem`, projeta as ações nela e apresenta título e descrição
|
|
26
|
+
como `PageIntro` no conteúdo. Na forma explícita dentro do shell, use
|
|
27
|
+
`Page > PageIntro (PageTitle, PageDescription?, PageActions?) + PageBody`. Não monte `PaneHeader`,
|
|
28
|
+
portal ou seletor global para reconstruir essa composição.
|
|
20
29
|
- `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
|
|
21
30
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
22
|
-
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho
|
|
23
|
-
incluindo o `PageBack
|
|
31
|
+
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page isolada
|
|
32
|
+
some inteiro — incluindo o `PageBack`; dentro de `PageShell`, somente `PageIntro` some e a barra
|
|
33
|
+
permanece. O estado ocupa a área disponível e seu título assume o heading
|
|
24
34
|
principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
|
|
25
35
|
dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
|
|
26
36
|
`role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Um Produto de Dados é um dataset governado e estável que uma ou mais Actions expõem.
|
|
3
|
+
* A declaração descreve o produto; autorização e recorte continuam pertencendo às Actions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type DataProductStatus = 'active' | 'deprecated'
|
|
7
|
+
|
|
8
|
+
export interface DataProductSource {
|
|
9
|
+
/** Identificador técnico estável da Fonte. */
|
|
10
|
+
id: string
|
|
11
|
+
/** Nome humano projetado por catálogos e lentes. */
|
|
12
|
+
label: string
|
|
13
|
+
description?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DataProductAccess {
|
|
17
|
+
/** Contextos de dados normalmente exigidos pelas interfaces do produto. Descritivo. */
|
|
18
|
+
contexts: readonly string[]
|
|
19
|
+
/** Eixos organizacionais que as Actions precisam considerar, como unit e team. Descritivo. */
|
|
20
|
+
organizationalScopes: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DataProductConfig {
|
|
24
|
+
/** Identidade estável e namespaced, como `sales.leads`. */
|
|
25
|
+
id: string
|
|
26
|
+
/** Versão semântica inteira da forma observável do produto. */
|
|
27
|
+
version: number
|
|
28
|
+
label: string
|
|
29
|
+
description: string
|
|
30
|
+
/** Responsável técnico ou de negócio pelo produto. */
|
|
31
|
+
owner: string
|
|
32
|
+
/** O que uma linha ou observação representa. */
|
|
33
|
+
grain: string
|
|
34
|
+
/** Classificação definida pelo produto, como `internal` ou `personal`. */
|
|
35
|
+
classification: string
|
|
36
|
+
/** Natureza definida pelo produto, como `real`, `demo` ou `synthetic`. */
|
|
37
|
+
nature: string
|
|
38
|
+
sources: readonly DataProductSource[]
|
|
39
|
+
/** Entidades Opus que materializam ou estruturam o produto. */
|
|
40
|
+
entities: readonly string[]
|
|
41
|
+
access: DataProductAccess
|
|
42
|
+
/** Nomes de Actions que disponibilizam este produto. */
|
|
43
|
+
interfaces: readonly string[]
|
|
44
|
+
status?: DataProductStatus
|
|
45
|
+
/** Produto sucessor quando este estiver descontinuado. */
|
|
46
|
+
replacedBy?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ID_RE = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/
|
|
50
|
+
|
|
51
|
+
function nonEmpty(value: unknown, field: string): void {
|
|
52
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
53
|
+
throw new TypeError(`DataProduct "${field}" deve ser string não-vazia`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Declara e valida um Produto de Dados sem introduzir dependência de banco ou runtime. */
|
|
58
|
+
export function defineDataProduct<const T extends DataProductConfig>(config: T): T {
|
|
59
|
+
if (!ID_RE.test(config.id)) {
|
|
60
|
+
throw new TypeError(`DataProduct id "${config.id}" deve ser namespaced e casar com ${ID_RE.source}`)
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isInteger(config.version) || config.version < 1) {
|
|
63
|
+
throw new TypeError('DataProduct "version" deve ser inteiro positivo')
|
|
64
|
+
}
|
|
65
|
+
for (const field of ['label', 'description', 'owner', 'grain', 'classification', 'nature'] as const) {
|
|
66
|
+
nonEmpty(config[field], field)
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(config.sources) || config.sources.length === 0) {
|
|
69
|
+
throw new TypeError('DataProduct "sources" deve declarar ao menos uma Fonte')
|
|
70
|
+
}
|
|
71
|
+
const sourceIds = new Set<string>()
|
|
72
|
+
for (const source of config.sources) {
|
|
73
|
+
nonEmpty(source.id, 'sources[].id')
|
|
74
|
+
nonEmpty(source.label, 'sources[].label')
|
|
75
|
+
if (source.description !== undefined) nonEmpty(source.description, 'sources[].description')
|
|
76
|
+
if (sourceIds.has(source.id)) throw new TypeError(`DataProduct Fonte "${source.id}" duplicada`)
|
|
77
|
+
sourceIds.add(source.id)
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(config.entities)) {
|
|
80
|
+
throw new TypeError('DataProduct "entities" deve ser uma lista')
|
|
81
|
+
}
|
|
82
|
+
if (new Set(config.entities).size !== config.entities.length) {
|
|
83
|
+
throw new TypeError(`DataProduct "${config.id}" possui Entity duplicada`)
|
|
84
|
+
}
|
|
85
|
+
for (const entity of config.entities) nonEmpty(entity, 'entities[]')
|
|
86
|
+
if (!Array.isArray(config.interfaces) || config.interfaces.length === 0) {
|
|
87
|
+
throw new TypeError('DataProduct "interfaces" deve declarar ao menos uma Action')
|
|
88
|
+
}
|
|
89
|
+
if (new Set(config.interfaces).size !== config.interfaces.length) {
|
|
90
|
+
throw new TypeError(`DataProduct "${config.id}" possui interface duplicada`)
|
|
91
|
+
}
|
|
92
|
+
for (const action of config.interfaces) nonEmpty(action, 'interfaces[]')
|
|
93
|
+
if (!Array.isArray(config.access?.contexts) || !Array.isArray(config.access?.organizationalScopes)) {
|
|
94
|
+
throw new TypeError('DataProduct "access" deve declarar contexts e organizationalScopes')
|
|
95
|
+
}
|
|
96
|
+
for (const context of config.access.contexts) nonEmpty(context, 'access.contexts[]')
|
|
97
|
+
for (const scope of config.access.organizationalScopes) nonEmpty(scope, 'access.organizationalScopes[]')
|
|
98
|
+
if (config.status !== undefined && config.status !== 'active' && config.status !== 'deprecated') {
|
|
99
|
+
throw new TypeError('DataProduct "status" deve ser "active" ou "deprecated"')
|
|
100
|
+
}
|
|
101
|
+
if (config.status === 'deprecated') {
|
|
102
|
+
nonEmpty(config.replacedBy, 'replacedBy')
|
|
103
|
+
if (!ID_RE.test(config.replacedBy!)) {
|
|
104
|
+
throw new TypeError(`DataProduct replacedBy "${config.replacedBy}" deve ser namespaced e casar com ${ID_RE.source}`)
|
|
105
|
+
}
|
|
106
|
+
if (config.replacedBy === config.id) throw new TypeError('DataProduct não pode substituir a si mesmo')
|
|
107
|
+
} else if (config.replacedBy !== undefined) {
|
|
108
|
+
throw new TypeError('DataProduct ativo não pode declarar "replacedBy"')
|
|
109
|
+
}
|
|
110
|
+
return config
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isDataProduct(value: unknown): value is DataProductConfig {
|
|
114
|
+
if (typeof value !== 'object' || value === null) return false
|
|
115
|
+
try {
|
|
116
|
+
defineDataProduct(value as DataProductConfig)
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/core/domain.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { ActionContract } from './contracts.ts'
|
|
25
|
+
import type { DataProductConfig } from './data-product.ts'
|
|
25
26
|
import { error } from './errors.ts'
|
|
26
27
|
import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
27
28
|
|
|
@@ -58,6 +59,9 @@ export interface DomainConfig {
|
|
|
58
59
|
* estrutural+negócio que vai pro manifest. */
|
|
59
60
|
entities?: Record<string, unknown>
|
|
60
61
|
|
|
62
|
+
/** Produtos de Dados governados disponibilizados pelas Actions deste domínio. */
|
|
63
|
+
dataProducts?: Record<string, DataProductConfig> | DataProductConfig[]
|
|
64
|
+
|
|
61
65
|
/** Repository class do domínio. */
|
|
62
66
|
repository?: unknown
|
|
63
67
|
|
|
@@ -90,6 +94,7 @@ export interface FlattenedDomain {
|
|
|
90
94
|
actions: ActionDef[]
|
|
91
95
|
reactions: Array<ReactionDef<any>>
|
|
92
96
|
schedules: ScheduleDef[]
|
|
97
|
+
dataProducts: DataProductConfig[]
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
// =============================================================================
|
|
@@ -150,6 +155,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
|
150
155
|
'subdomains' in v ||
|
|
151
156
|
'dicts' in v ||
|
|
152
157
|
'entities' in v ||
|
|
158
|
+
'dataProducts' in v ||
|
|
153
159
|
'repository' in v ||
|
|
154
160
|
'service' in v
|
|
155
161
|
)
|
|
@@ -160,7 +166,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
|
160
166
|
* pro `Runtime.register`.
|
|
161
167
|
*/
|
|
162
168
|
export function flattenDomain(domain: DomainConfig): FlattenedDomain {
|
|
163
|
-
const out: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
169
|
+
const out: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
164
170
|
collect(domain, out)
|
|
165
171
|
return out
|
|
166
172
|
}
|
|
@@ -179,6 +185,12 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
|
|
|
179
185
|
if (domain.schedules !== undefined) {
|
|
180
186
|
for (const schedule of domain.schedules) out.schedules.push(schedule)
|
|
181
187
|
}
|
|
188
|
+
if (domain.dataProducts !== undefined) {
|
|
189
|
+
const products = Array.isArray(domain.dataProducts)
|
|
190
|
+
? domain.dataProducts
|
|
191
|
+
: Object.values(domain.dataProducts)
|
|
192
|
+
for (const product of products) out.dataProducts.push(product)
|
|
193
|
+
}
|
|
182
194
|
if (domain.subdomains !== undefined) {
|
|
183
195
|
for (const sub of domain.subdomains) collect(sub, out)
|
|
184
196
|
}
|
|
@@ -257,7 +269,7 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
|
|
|
257
269
|
// — actions: nomes únicos no domínio (e subdomínios) —
|
|
258
270
|
// (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
|
|
259
271
|
// primeiro, antes de detectar duplicatas cruzadas.)
|
|
260
|
-
const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
272
|
+
const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
|
|
261
273
|
collect(domain, collected)
|
|
262
274
|
|
|
263
275
|
const actionNames = new Set<string>()
|
|
@@ -271,6 +283,48 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
|
|
|
271
283
|
}
|
|
272
284
|
actionNames.add(action.name)
|
|
273
285
|
}
|
|
286
|
+
|
|
287
|
+
const entityNames = new Set<string>()
|
|
288
|
+
const collectEntityNames = (current: DomainConfig): void => {
|
|
289
|
+
for (const [key, raw] of Object.entries(current.entities ?? {})) {
|
|
290
|
+
const declared = typeof raw === 'object' && raw !== null && 'name' in raw
|
|
291
|
+
? (raw as { name?: unknown }).name
|
|
292
|
+
: undefined
|
|
293
|
+
entityNames.add(typeof declared === 'string' && declared !== '' ? declared : key)
|
|
294
|
+
}
|
|
295
|
+
for (const subdomain of current.subdomains ?? []) collectEntityNames(subdomain)
|
|
296
|
+
}
|
|
297
|
+
collectEntityNames(domain)
|
|
298
|
+
|
|
299
|
+
const productIds = new Set<string>()
|
|
300
|
+
for (const product of collected.dataProducts) {
|
|
301
|
+
if (productIds.has(product.id)) {
|
|
302
|
+
throw error({
|
|
303
|
+
code: 'domain.duplicate_data_product',
|
|
304
|
+
category: 'internal',
|
|
305
|
+
message: `DataProduct "${product.id}" duplicado em "${fullPathStr}"`,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
productIds.add(product.id)
|
|
309
|
+
for (const actionName of product.interfaces) {
|
|
310
|
+
if (!actionNames.has(actionName)) {
|
|
311
|
+
throw error({
|
|
312
|
+
code: 'domain.data_product_interface_not_found',
|
|
313
|
+
category: 'internal',
|
|
314
|
+
message: `DataProduct "${product.id}" referencia a Action inexistente "${actionName}"`,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const entityName of product.entities) {
|
|
319
|
+
if (!entityNames.has(entityName)) {
|
|
320
|
+
throw error({
|
|
321
|
+
code: 'domain.data_product_entity_not_found',
|
|
322
|
+
category: 'internal',
|
|
323
|
+
message: `DataProduct "${product.id}" referencia a Entity inexistente "${entityName}"`,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
274
328
|
}
|
|
275
329
|
|
|
276
330
|
/**
|
package/src/core/index.ts
CHANGED
|
@@ -200,6 +200,15 @@ export { defineSchedule, isSchedule } from './schedules.ts'
|
|
|
200
200
|
export { defineDomain, flattenDomain, isDomainConfig } from './domain.ts'
|
|
201
201
|
export type { DomainConfig, FlattenedDomain } from './domain.ts'
|
|
202
202
|
|
|
203
|
+
// — Produtos de Dados ————————————————————————————————————————————————————————
|
|
204
|
+
export { defineDataProduct, isDataProduct } from './data-product.ts'
|
|
205
|
+
export type {
|
|
206
|
+
DataProductAccess,
|
|
207
|
+
DataProductConfig,
|
|
208
|
+
DataProductSource,
|
|
209
|
+
DataProductStatus,
|
|
210
|
+
} from './data-product.ts'
|
|
211
|
+
|
|
203
212
|
// — Audit —————————————————————————————————————————————————————————————————————
|
|
204
213
|
export { AuditEmitter } from './audit.ts'
|
|
205
214
|
|
package/src/core/runtime.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { isBackgroundAction } from './actions.ts'
|
|
|
16
16
|
import { isSchedule } from './schedules.ts'
|
|
17
17
|
import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.ts'
|
|
18
18
|
import type { DomainConfig } from './domain.ts'
|
|
19
|
+
import type { DataProductConfig } from './data-product.ts'
|
|
19
20
|
import { AuditEmitter } from './audit.ts'
|
|
20
21
|
import { error, isActionError, normalizeError } from './errors.ts'
|
|
21
22
|
import { normalizeTraceContext } from './trace.ts'
|
|
@@ -254,6 +255,8 @@ export class Runtime {
|
|
|
254
255
|
private readonly actions = new Map<string, ActionDef>()
|
|
255
256
|
private readonly reactions = new Map<string, ReactionDef<any>>()
|
|
256
257
|
private readonly schedules = new Map<string, ScheduleDef>()
|
|
258
|
+
private readonly dataProducts = new Map<string, DataProductConfig>()
|
|
259
|
+
private readonly actionDataProducts = new Map<string, Set<string>>()
|
|
257
260
|
|
|
258
261
|
/**
|
|
259
262
|
* Map entidade → resolver, usado pra carregar specs DSL de loads
|
|
@@ -315,8 +318,8 @@ export class Runtime {
|
|
|
315
318
|
for (const item of items) {
|
|
316
319
|
if (isDomainConfig(item)) {
|
|
317
320
|
collectDomainWarnings(item)
|
|
318
|
-
const { actions, reactions, schedules } = flattenDomain(item)
|
|
319
|
-
this.registerFlat(actions, reactions, schedules)
|
|
321
|
+
const { actions, reactions, schedules, dataProducts } = flattenDomain(item)
|
|
322
|
+
this.registerFlat(actions, reactions, schedules, dataProducts)
|
|
320
323
|
} else if (isSchedule(item)) {
|
|
321
324
|
this.registerSchedules([item])
|
|
322
325
|
} else if (isReaction(item)) {
|
|
@@ -356,10 +359,31 @@ export class Runtime {
|
|
|
356
359
|
actions: ActionDef[],
|
|
357
360
|
reactions: Array<ReactionDef<any>>,
|
|
358
361
|
schedules: ScheduleDef[],
|
|
362
|
+
dataProducts: DataProductConfig[],
|
|
359
363
|
): void {
|
|
360
364
|
this.registerActions(actions)
|
|
361
365
|
this.registerReactions(reactions)
|
|
362
366
|
this.registerSchedules(schedules)
|
|
367
|
+
this.registerDataProducts(dataProducts)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private registerDataProducts(products: DataProductConfig[]): void {
|
|
371
|
+
for (const product of products) {
|
|
372
|
+
if (this.dataProducts.has(product.id)) {
|
|
373
|
+
throw error({
|
|
374
|
+
code: 'runtime.duplicate_data_product',
|
|
375
|
+
category: 'internal',
|
|
376
|
+
message: `DataProduct "${product.id}" registrado duas vezes`,
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
this.dataProducts.set(product.id, product)
|
|
380
|
+
for (const action of product.interfaces) {
|
|
381
|
+
const related = this.actionDataProducts.get(action) ?? new Set<string>()
|
|
382
|
+
related.add(product.id)
|
|
383
|
+
this.actionDataProducts.set(action, related)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
this.aiToolsCache = null
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
private registerActions(actions: ActionDef[]): void {
|
|
@@ -795,6 +819,7 @@ export class Runtime {
|
|
|
795
819
|
name: action.name,
|
|
796
820
|
description: cfg.description ?? action.description ?? action.name,
|
|
797
821
|
inputSchema: action.input,
|
|
822
|
+
metadata: { dataProducts: [...(this.actionDataProducts.get(action.name) ?? [])].sort() },
|
|
798
823
|
})
|
|
799
824
|
}
|
|
800
825
|
this.aiToolsCache = tools
|
package/src/core/types.ts
CHANGED
|
@@ -1261,6 +1261,8 @@ export interface AiTool {
|
|
|
1261
1261
|
name: string
|
|
1262
1262
|
description: string
|
|
1263
1263
|
inputSchema: unknown
|
|
1264
|
+
/** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
|
|
1265
|
+
metadata?: { dataProducts?: string[] }
|
|
1264
1266
|
}
|
|
1265
1267
|
|
|
1266
1268
|
/** Turno de conversa (histórico multi-turn do chat). */
|
package/src/mcp/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
|
|
|
46
46
|
name: t.name,
|
|
47
47
|
description: t.description,
|
|
48
48
|
inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
|
|
49
|
+
_meta: { 'com.softize.opus/data-products': t.metadata?.dataProducts ?? [] },
|
|
49
50
|
})),
|
|
50
51
|
}))
|
|
51
52
|
|