@softize/opus 12.11.0 → 13.1.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 +76 -0
- package/PROMOTED.md +46 -0
- package/README.md +28 -19
- package/bin/cli.mjs +87 -216
- package/bin/lib/check.mjs +2 -7
- package/bin/lib/cli-shared.mjs +131 -0
- package/bin/lib/copy.mjs +1 -5
- package/bin/lib/db.mjs +16 -74
- package/bin/lib/gen-openapi.mjs +3 -3
- package/bin/lib/gen-runner.mjs +1 -1
- package/bin/lib/gen.mjs +14 -69
- package/bin/lib/mcp.mjs +3 -1
- package/bin/lib/seed.mjs +5 -62
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +93 -10
- package/docs/adr/0007-toast-actions-form-an-ordered-collection.md +63 -0
- package/docs/adr/0008-hierarchical-navigation-is-composed-at-the-consumer-boundary.md +71 -0
- package/docs/ownership-vs-shadcn-lock.md +2 -3
- package/docs/protocol.md +7 -7
- package/docs/radius-scale.md +1 -1
- package/docs/releasing.md +8 -2
- package/package.json +7 -3
- package/registry/skills/maintain-opus-docs/SKILL.md +83 -0
- package/registry/skills/maintain-opus-docs/agents/openai.yaml +4 -0
- package/registry/skills/maintain-opus-docs/references/editorial-standard.md +85 -0
- package/registry/skills/maintain-opus-docs/references/evaluations.md +34 -0
- package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +81 -0
- package/registry/templates/app/package.json +1 -1
- package/registry/templates/app/src/main.tsx +4 -4
- package/src/audit/drivers/console.ts +1 -0
- package/src/auth/drivers/better-auth.ts +1 -0
- package/src/auth/drivers/jwt.ts +1 -0
- package/src/cache/drivers/memory.ts +1 -0
- package/src/client/drivers/fetch.ts +2 -1
- package/src/core/actions.ts +6 -1
- package/src/core/audit.ts +9 -3
- package/src/core/contracts.ts +7 -0
- package/src/core/domain.ts +1 -1
- package/src/core/errors.ts +18 -15
- package/src/core/index.ts +4 -2
- package/src/core/package-version.ts +26 -0
- package/src/core/reactions.ts +1 -1
- package/src/core/runtime.ts +33 -23
- package/src/core/schedules.ts +1 -1
- package/src/core/types.ts +2 -2
- package/src/dsl/eval.ts +2 -2
- package/src/dsl/kysely.ts +2 -2
- package/src/dsl/loads.ts +1 -1
- package/src/dsl/parser.ts +5 -5
- package/src/events/drivers/mitt.ts +1 -0
- package/src/mcp/index.ts +2 -1
- package/src/observability/drivers/opentelemetry.ts +1 -0
- package/src/queue/drivers/bullmq.ts +3 -3
- package/src/scheduler/drivers/node-cron.ts +3 -2
- package/src/scheduler/every.ts +7 -7
- package/src/schema/openapi.ts +3 -3
- package/src/seed/index.ts +29 -0
- package/src/server/drivers/fastify.ts +5 -2
- package/src/server/drivers/node.ts +9 -6
- package/src/server/index.ts +3 -1
- package/src/storage/drivers/fs.ts +1 -0
- package/src/testing/index.ts +3 -3
- package/src/ui/components/patterns/confirm.tsx +142 -42
- package/src/ui/components/patterns/content-header.tsx +7 -1
- package/src/ui/components/patterns/data-state.tsx +1 -1
- package/src/ui/components/patterns/dock.tsx +20 -3
- package/src/ui/components/patterns/form.tsx +12 -8
- package/src/ui/components/patterns/list.tsx +36 -41
- package/src/ui/components/patterns/page-state.tsx +2 -2
- package/src/ui/components/patterns/page.tsx +19 -1
- package/src/ui/components/patterns/shell-nav.tsx +10 -3
- package/src/ui/components/patterns/sidebar.tsx +43 -32
- package/src/ui/components/patterns/trigger.tsx +39 -38
- package/src/ui/components/patterns/view.tsx +26 -17
- package/src/ui/components/primitives/alert.tsx +14 -8
- package/src/ui/components/primitives/ask.tsx +3 -3
- package/src/ui/components/primitives/badge.tsx +11 -6
- package/src/ui/components/primitives/breadcrumb.tsx +2 -2
- package/src/ui/components/primitives/button.tsx +16 -3
- package/src/ui/components/primitives/calendar.tsx +28 -2
- package/src/ui/components/primitives/carousel.tsx +3 -3
- package/src/ui/components/primitives/chat.tsx +1 -1
- package/src/ui/components/primitives/checkbox.tsx +1 -1
- package/src/ui/components/primitives/command.tsx +2 -2
- package/src/ui/components/primitives/control.ts +12 -0
- package/src/ui/components/primitives/copyable.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +202 -40
- package/src/ui/components/primitives/dot.tsx +5 -0
- package/src/ui/components/primitives/drawer.tsx +18 -8
- package/src/ui/components/primitives/empty.tsx +3 -3
- package/src/ui/components/primitives/field.tsx +3 -3
- package/src/ui/components/primitives/icon-picker.tsx +3 -1
- package/src/ui/components/primitives/input-group.tsx +1 -1
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/input.tsx +2 -2
- package/src/ui/components/primitives/item.tsx +3 -3
- package/src/ui/components/primitives/progress.tsx +32 -3
- package/src/ui/components/primitives/radio-group.tsx +1 -1
- package/src/ui/components/primitives/resizable.tsx +3 -1
- package/src/ui/components/primitives/select.tsx +5 -5
- package/src/ui/components/primitives/slider.tsx +5 -1
- package/src/ui/components/primitives/sonner.tsx +190 -8
- package/src/ui/components/primitives/switch.tsx +1 -0
- package/src/ui/components/primitives/tabs.tsx +1 -0
- package/src/ui/components/primitives/textarea.tsx +1 -1
- package/src/ui/components/primitives/toggle.tsx +1 -1
- package/src/ui/components/primitives/tooltip.tsx +1 -0
- package/src/ui/docs/DocBrowser.tsx +102 -23
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/accordion.md +22 -16
- package/src/ui/docs/content/action-form-card.md +8 -8
- package/src/ui/docs/content/action-form-dialog.md +9 -9
- package/src/ui/docs/content/action-form.md +37 -36
- package/src/ui/docs/content/action-list-dialog.md +11 -6
- package/src/ui/docs/content/action-list.md +73 -39
- package/src/ui/docs/content/action-trigger.md +29 -15
- package/src/ui/docs/content/action-view.md +17 -10
- package/src/ui/docs/content/actions.md +9 -9
- package/src/ui/docs/content/ai.md +3 -3
- package/src/ui/docs/content/alert.md +14 -12
- package/src/ui/docs/content/ask.md +11 -0
- package/src/ui/docs/content/aspect-ratio.md +4 -4
- package/src/ui/docs/content/audit.md +2 -2
- package/src/ui/docs/content/auth.md +3 -3
- package/src/ui/docs/content/avatar.md +34 -14
- package/src/ui/docs/content/badge.md +3 -3
- package/src/ui/docs/content/breadcrumb.md +13 -8
- package/src/ui/docs/content/button.md +81 -6
- package/src/ui/docs/content/calendar.md +18 -5
- package/src/ui/docs/content/card.md +27 -1
- package/src/ui/docs/content/carousel.md +16 -11
- package/src/ui/docs/content/chat.md +23 -3
- package/src/ui/docs/content/checkbox.md +7 -7
- package/src/ui/docs/content/cli.md +74 -22
- package/src/ui/docs/content/collapsible.md +8 -8
- package/src/ui/docs/content/command.md +16 -8
- package/src/ui/docs/content/composer.md +17 -2
- package/src/ui/docs/content/content.md +17 -2
- package/src/ui/docs/content/copyable.md +12 -3
- package/src/ui/docs/content/customization.md +5 -5
- package/src/ui/docs/content/cycle.md +3 -3
- package/src/ui/docs/content/data-state.md +11 -12
- package/src/ui/docs/content/data.md +26 -33
- package/src/ui/docs/content/detail.md +22 -4
- package/src/ui/docs/content/dialog.md +339 -31
- package/src/ui/docs/content/dictionary-value.md +17 -10
- package/src/ui/docs/content/dock.md +11 -3
- package/src/ui/docs/content/dot.md +8 -0
- package/src/ui/docs/content/drawer.md +27 -14
- package/src/ui/docs/content/empty-value.md +2 -2
- package/src/ui/docs/content/empty.md +19 -12
- package/src/ui/docs/content/events.md +4 -4
- package/src/ui/docs/content/field.md +34 -12
- package/src/ui/docs/content/getting-started.md +3 -3
- package/src/ui/docs/content/icon-picker.md +19 -4
- package/src/ui/docs/content/input-otp.md +20 -12
- package/src/ui/docs/content/input.md +121 -9
- package/src/ui/docs/content/item.md +27 -13
- package/src/ui/docs/content/kbd.md +19 -11
- package/src/ui/docs/content/label.md +12 -3
- package/src/ui/docs/content/log.md +4 -4
- package/src/ui/docs/content/markdown.md +7 -6
- package/src/ui/docs/content/mcp.md +13 -15
- package/src/ui/docs/content/menu.md +40 -16
- package/src/ui/docs/content/metric-card.md +13 -0
- package/src/ui/docs/content/observability.md +2 -2
- package/src/ui/docs/content/page.md +59 -6
- package/src/ui/docs/content/pagination.md +22 -17
- package/src/ui/docs/content/popover.md +22 -8
- package/src/ui/docs/content/progress.md +15 -16
- package/src/ui/docs/content/queue.md +5 -5
- package/src/ui/docs/content/radio-group.md +20 -12
- package/src/ui/docs/content/router.md +11 -6
- package/src/ui/docs/content/scheduler.md +4 -5
- package/src/ui/docs/content/scroll-area.md +12 -7
- package/src/ui/docs/content/select.md +47 -34
- package/src/ui/docs/content/semantic-context.md +2 -2
- package/src/ui/docs/content/separator.md +5 -5
- package/src/ui/docs/content/sidebar.md +329 -54
- package/src/ui/docs/content/skeleton.md +9 -2
- package/src/ui/docs/content/slider.md +8 -7
- package/src/ui/docs/content/spinner.md +8 -8
- package/src/ui/docs/content/split.md +29 -5
- package/src/ui/docs/content/storage.md +6 -8
- package/src/ui/docs/content/switch.md +8 -7
- package/src/ui/docs/content/table.md +13 -3
- package/src/ui/docs/content/tabs.md +28 -14
- package/src/ui/docs/content/testing.md +9 -11
- package/src/ui/docs/content/textarea.md +12 -4
- package/src/ui/docs/content/toast.md +47 -13
- package/src/ui/docs/content/toggle.md +75 -7
- package/src/ui/docs/content/tokens.md +7 -7
- package/src/ui/docs/content/tooltip.md +19 -11
- package/src/ui/docs/content/truncate.md +15 -8
- package/src/ui/docs/content/ui.md +24 -9
- package/src/ui/docs/content/upgrading.md +7 -8
- package/src/ui/docs/doc-client.tsx +5 -5
- package/src/ui/docs/doc.tsx +26 -14
- package/src/ui/docs/registry.tsx +25 -42
- package/src/ui/docs/standalone.tsx +2 -2
- package/src/ui/drivers/react.tsx +17 -12
- package/src/ui/lib/action-errors.ts +45 -0
- package/src/ui/lib/zod-pt-br.ts +31 -4
- package/src/ui/meta.ts +65 -95
- package/src/ui/react.tsx +17 -16
- package/src/ui/theme.css +60 -8
- package/src/vite/design.ts +6 -18
- package/src/ui/components/primitives/alert-dialog.tsx +0 -192
- package/src/ui/docs/content/alert-dialog.md +0 -73
- package/src/ui/docs/content/button-group.md +0 -71
- package/src/ui/docs/content/confirm.md +0 -120
- package/src/ui/docs/content/input-group.md +0 -79
- package/src/ui/docs/content/page-state.md +0 -45
- package/src/ui/docs/content/toggle-group.md +0 -81
package/src/dsl/loads.ts
CHANGED
|
@@ -31,7 +31,7 @@ export function parseLoad(src: string): LoadSpec {
|
|
|
31
31
|
const trimmed = src.trim()
|
|
32
32
|
const m = LOAD_RE.exec(trimmed)
|
|
33
33
|
if (m === null) {
|
|
34
|
-
throw new Error(`DSL loads:
|
|
34
|
+
throw new Error(`DSL loads: sintaxe inválida '${src}'`)
|
|
35
35
|
}
|
|
36
36
|
const entity = m[1]!
|
|
37
37
|
const argsRaw = m[2]!.trim()
|
package/src/dsl/parser.ts
CHANGED
|
@@ -89,7 +89,7 @@ function tokenize(src: string): Token[] {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
if (i >= src.length) {
|
|
92
|
-
throw new Error(`DSL parse
|
|
92
|
+
throw new Error(`DSL parse: string não terminada na posição ${startPos}`)
|
|
93
93
|
}
|
|
94
94
|
i += 1 // skip closing quote
|
|
95
95
|
tokens.push({ type: 'STRING', value: val, pos: startPos })
|
|
@@ -150,7 +150,7 @@ function tokenize(src: string): Token[] {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
throw new Error(
|
|
153
|
-
`DSL parse
|
|
153
|
+
`DSL parse: caractere inesperado '${c}' na posição ${i}`,
|
|
154
154
|
)
|
|
155
155
|
}
|
|
156
156
|
tokens.push({ type: 'EOF', value: '', pos: src.length })
|
|
@@ -184,7 +184,7 @@ class Parser {
|
|
|
184
184
|
if (!this.match(type, value)) {
|
|
185
185
|
const t = this.peek()
|
|
186
186
|
throw new Error(
|
|
187
|
-
`DSL parse
|
|
187
|
+
`DSL parse: esperado ${value ?? type}, recebido '${t.value}' na posição ${t.pos}`,
|
|
188
188
|
)
|
|
189
189
|
}
|
|
190
190
|
return this.consume()
|
|
@@ -343,7 +343,7 @@ class Parser {
|
|
|
343
343
|
const t = this.consume()
|
|
344
344
|
const n = Number(t.value)
|
|
345
345
|
if (Number.isNaN(n)) {
|
|
346
|
-
throw new Error(`DSL parse
|
|
346
|
+
throw new Error(`DSL parse: número inválido '${t.value}'`)
|
|
347
347
|
}
|
|
348
348
|
return { kind: 'literal', value: n }
|
|
349
349
|
}
|
|
@@ -409,7 +409,7 @@ class Parser {
|
|
|
409
409
|
}
|
|
410
410
|
const t = this.peek()
|
|
411
411
|
throw new Error(
|
|
412
|
-
`DSL parse
|
|
412
|
+
`DSL parse: token inesperado '${t.value}' na posição ${t.pos}`,
|
|
413
413
|
)
|
|
414
414
|
}
|
|
415
415
|
}
|
|
@@ -39,6 +39,7 @@ export interface MittEventsOptions {
|
|
|
39
39
|
*/
|
|
40
40
|
type EventMap = Record<string, DomainEvent>
|
|
41
41
|
|
|
42
|
+
/** EventBusAdapter in-process baseado em `mitt`; entrega no mesmo processo, sem persistência. */
|
|
42
43
|
export function mittEvents(options: MittEventsOptions = {}): EventBusAdapter {
|
|
43
44
|
const { name = 'mitt' } = options
|
|
44
45
|
const emitter: Emitter<EventMap> = mitt<EventMap>()
|
package/src/mcp/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
|
13
13
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
14
14
|
import { zodToJsonSchema } from 'zod-to-json-schema'
|
|
15
15
|
import type { ContextBase, Runtime } from '../core/index.ts'
|
|
16
|
+
import { readPackageVersion } from '../core/package-version.ts'
|
|
16
17
|
|
|
17
18
|
export interface OpusMcpOptions {
|
|
18
19
|
name?: string
|
|
@@ -36,7 +37,7 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
|
|
|
36
37
|
opts.toJsonSchema ?? ((schema: unknown) => zodToJsonSchema(schema as never, { $refStrategy: 'none' }))
|
|
37
38
|
|
|
38
39
|
const server = new Server(
|
|
39
|
-
{ name: opts.name ?? 'opus', version: opts.version ??
|
|
40
|
+
{ name: opts.name ?? 'opus', version: opts.version ?? readPackageVersion() },
|
|
40
41
|
{ capabilities: { tools: {} } },
|
|
41
42
|
)
|
|
42
43
|
|
|
@@ -40,6 +40,7 @@ export interface OpenTelemetryObservabilityOptions {
|
|
|
40
40
|
healthCheck?: () => Promise<HealthStatus> | HealthStatus
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** ObservabilityAdapter que abre um span OpenTelemetry por action/reaction e propaga o trace context. */
|
|
43
44
|
export function openTelemetryObservability(
|
|
44
45
|
options: OpenTelemetryObservabilityOptions = {},
|
|
45
46
|
): ObservabilityAdapter {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* import { Queue } from 'bullmq'
|
|
14
14
|
* import { bullmqQueue } from '@softize/opus/queue/bullmq'
|
|
15
15
|
*
|
|
16
|
-
* const queue = new Queue('
|
|
16
|
+
* const queue = new Queue('opus-jobs', { connection: redisOpts })
|
|
17
17
|
* createRuntime({ queue: bullmqQueue({ queue }) })
|
|
18
18
|
*/
|
|
19
19
|
|
|
@@ -139,9 +139,9 @@ function bullmqOptions(spec: JobSpec): BullMQAddOptions {
|
|
|
139
139
|
// =============================================================================
|
|
140
140
|
|
|
141
141
|
/**
|
|
142
|
-
* BullMQ state →
|
|
142
|
+
* BullMQ state → Opus JobStatus.
|
|
143
143
|
*
|
|
144
|
-
*
|
|
144
|
+
* O Opus não tem 'paused' nem 'delayed' separados — todos viram 'queued'.
|
|
145
145
|
* Distinção fina fica no `details.meta.bullmqState` se consumer precisar.
|
|
146
146
|
*/
|
|
147
147
|
export function mapState(state: BullMQState): JobStatus {
|
|
@@ -31,6 +31,7 @@ export interface NodeCronOptions {
|
|
|
31
31
|
onError?: (err: unknown, schedule: ScheduleDef) => void
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** SchedulerAdapter sobre `node-cron`; aceita `cron` ou o atalho `every` de cada schedule. */
|
|
34
35
|
export function nodeCronScheduler(
|
|
35
36
|
options: NodeCronOptions = {},
|
|
36
37
|
): SchedulerAdapter {
|
|
@@ -83,11 +84,11 @@ function resolveCron(spec: ScheduleDef): string {
|
|
|
83
84
|
if (spec.cron !== undefined) return spec.cron
|
|
84
85
|
if (spec.every !== undefined) return everyToCron(spec.every)
|
|
85
86
|
throw new Error(
|
|
86
|
-
`
|
|
87
|
+
`O schedule "${spec.name}" precisa declarar 'cron' ou 'every'.`,
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function defaultOnError(err: unknown, schedule: ScheduleDef): void {
|
|
91
92
|
// eslint-disable-next-line no-console
|
|
92
|
-
console.error(`[
|
|
93
|
+
console.error(`[opus/scheduler] falha ao disparar o schedule "${schedule.name}":`, err)
|
|
93
94
|
}
|
package/src/scheduler/every.ts
CHANGED
|
@@ -15,30 +15,30 @@ export function everyToCron(every: string): string {
|
|
|
15
15
|
const match = /^(\d+)(s|m|h|d)$/.exec(every.trim())
|
|
16
16
|
if (match === null) {
|
|
17
17
|
throw new Error(
|
|
18
|
-
`
|
|
19
|
-
`
|
|
18
|
+
`Atalho 'every' inválido: "${every}". ` +
|
|
19
|
+
`Formato esperado "<n><s|m|h|d>" (ex: "15s", "5m", "1h", "1d").`,
|
|
20
20
|
)
|
|
21
21
|
}
|
|
22
22
|
const n = Number.parseInt(match[1] as string, 10)
|
|
23
23
|
const unit = match[2] as 's' | 'm' | 'h' | 'd'
|
|
24
24
|
|
|
25
25
|
if (n <= 0) {
|
|
26
|
-
throw new Error(`'every'
|
|
26
|
+
throw new Error(`O valor de 'every' precisa ser maior que 0; recebido "${every}".`)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
switch (unit) {
|
|
30
30
|
case 's':
|
|
31
|
-
if (n > 59) throw new Error(`'every'
|
|
31
|
+
if (n > 59) throw new Error(`Segundos em 'every' precisam ser no máximo 59; recebido ${n}.`)
|
|
32
32
|
return `*/${n} * * * * *`
|
|
33
33
|
case 'm':
|
|
34
|
-
if (n > 59) throw new Error(`'every'
|
|
34
|
+
if (n > 59) throw new Error(`Minutos em 'every' precisam ser no máximo 59; recebido ${n}.`)
|
|
35
35
|
return `*/${n} * * * *`
|
|
36
36
|
case 'h':
|
|
37
|
-
if (n > 23) throw new Error(`'every'
|
|
37
|
+
if (n > 23) throw new Error(`Horas em 'every' precisam ser no máximo 23; recebido ${n}.`)
|
|
38
38
|
return n === 1 ? '0 * * * *' : `0 */${n} * * *`
|
|
39
39
|
case 'd':
|
|
40
40
|
if (n !== 1) {
|
|
41
|
-
throw new Error(`'every'
|
|
41
|
+
throw new Error(`Dias em 'every' aceitam somente 1d. Para intervalos maiores, use cron.`)
|
|
42
42
|
}
|
|
43
43
|
return '0 0 * * *'
|
|
44
44
|
}
|
package/src/schema/openapi.ts
CHANGED
|
@@ -175,15 +175,15 @@ function buildResponses(
|
|
|
175
175
|
const successStatus = String(action.successStatus ?? 200)
|
|
176
176
|
const responses: Record<string, OpenAPIResponse> = {
|
|
177
177
|
[successStatus]: {
|
|
178
|
-
description: '
|
|
178
|
+
description: 'Sucesso',
|
|
179
179
|
content: {
|
|
180
180
|
'application/json': {
|
|
181
181
|
schema: wrapEnvelope(toJsonSchema(action.output), action.kind),
|
|
182
182
|
},
|
|
183
183
|
},
|
|
184
184
|
},
|
|
185
|
-
'4XX': errorResponse('
|
|
186
|
-
'5XX': errorResponse('
|
|
185
|
+
'4XX': errorResponse('Erro do cliente', action.errors),
|
|
186
|
+
'5XX': errorResponse('Erro do servidor'),
|
|
187
187
|
}
|
|
188
188
|
return responses
|
|
189
189
|
}
|
package/src/seed/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* implementação server-only que a CLI carrega pelo `opus.config.ts`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** Variante de dados que um seed sabe aplicar (ex.: `minimal`, `demo`). */
|
|
8
9
|
export interface SeedProfile {
|
|
9
10
|
/** Situação que este perfil representa para quem vai aplicá-lo. */
|
|
10
11
|
description: string
|
|
@@ -12,13 +13,16 @@ export interface SeedProfile {
|
|
|
12
13
|
expected?: Readonly<Record<string, number>>
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Limites de onde o seed pode rodar. */
|
|
15
17
|
export interface SeedSafety {
|
|
16
18
|
/** Escopos de dados em que o seed pode abrir conexão. */
|
|
17
19
|
scopes: readonly SeedScope[]
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/** Escopos de banco em que seeds podem abrir conexão; nunca produção. */
|
|
20
23
|
export type SeedScope = 'local' | 'test' | 'isolated-preview'
|
|
21
24
|
|
|
25
|
+
/** Declaração pura de um seed — o que `defineSeed` valida e o manifesto publica. */
|
|
22
26
|
export interface SeedDefinition {
|
|
23
27
|
/** Identidade estável, em segmentos lowercase separados por ponto. */
|
|
24
28
|
name: string
|
|
@@ -31,12 +35,14 @@ export interface SeedDefinition {
|
|
|
31
35
|
safety: SeedSafety
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** Contexto entregue a `plan`, `apply` e `verify` do binding. */
|
|
34
39
|
export interface SeedContext<Database = unknown> {
|
|
35
40
|
database: Database | null
|
|
36
41
|
profile: string
|
|
37
42
|
scope: SeedScope
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/** Contexto da asserção de alvo, executada antes de qualquer binding. */
|
|
40
46
|
export interface SeedTargetContext<Database = unknown> {
|
|
41
47
|
database: Database | null
|
|
42
48
|
scope: SeedScope
|
|
@@ -50,28 +56,34 @@ export type SeedTargetAssertion<Database = unknown> = (
|
|
|
50
56
|
context: SeedTargetContext<Database>,
|
|
51
57
|
) => boolean | Promise<boolean>
|
|
52
58
|
|
|
59
|
+
/** Resultado de `plan`: o que a aplicação faria, sem tocar o banco. */
|
|
53
60
|
export interface SeedPlan {
|
|
54
61
|
summary: string
|
|
55
62
|
operations: readonly string[]
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/** Resultado de `apply` e `verify`: resumo e métricas observadas. */
|
|
58
66
|
export interface SeedReport {
|
|
59
67
|
summary: string
|
|
60
68
|
metrics: Readonly<Record<string, number>>
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/** Implementação server-only de um seed, ligada à declaração por `bindSeed`. */
|
|
63
72
|
export interface SeedBinding<Database = unknown> {
|
|
64
73
|
plan(context: SeedContext<Database>): SeedPlan | Promise<SeedPlan>
|
|
65
74
|
apply(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
|
|
66
75
|
verify(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
/** Seed com declaração e binding — a forma que o registro em `opus.config.ts` consome. */
|
|
69
79
|
export interface BoundSeed<Database = unknown> extends SeedDefinition {
|
|
70
80
|
readonly binding: SeedBinding<Database>
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/** Item aceito no registro; declaração sem binding é diagnosticada como `seed.unbound`. */
|
|
73
84
|
export type SeedRegistryItem = SeedDefinition | BoundSeed
|
|
74
85
|
|
|
86
|
+
/** Problema encontrado ao verificar o registro de seeds. */
|
|
75
87
|
export interface SeedDiagnostic {
|
|
76
88
|
code:
|
|
77
89
|
| 'seed.duplicate'
|
|
@@ -82,6 +94,7 @@ export interface SeedDiagnostic {
|
|
|
82
94
|
seed: string
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/** Resultado de `checkSeedRegistry`. */
|
|
85
98
|
export interface SeedRegistryCheck {
|
|
86
99
|
ok: boolean
|
|
87
100
|
diagnostics: readonly SeedDiagnostic[]
|
|
@@ -94,6 +107,7 @@ const PROFILE_RE = /^[a-z][a-z0-9-]*$/
|
|
|
94
107
|
const SCOPE_RE = /^[a-z][a-z0-9-]*$/
|
|
95
108
|
const SEED_SCOPES = new Set<SeedScope>(['local', 'test', 'isolated-preview'])
|
|
96
109
|
|
|
110
|
+
/** Valida e devolve a declaração de um seed, preservando o tipo literal. Lança em declaração inválida. */
|
|
97
111
|
export function defineSeed<const Definition extends SeedDefinition>(
|
|
98
112
|
definition: Definition,
|
|
99
113
|
): Definition {
|
|
@@ -129,6 +143,7 @@ export function publicSeedDefinition(seed: SeedDefinition): SeedDefinition {
|
|
|
129
143
|
}
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
/** Liga a implementação server-only (`plan`, `apply`, `verify`) a uma declaração validada. */
|
|
132
147
|
export function bindSeed<Database = unknown>(
|
|
133
148
|
definition: SeedDefinition,
|
|
134
149
|
binding: SeedBinding<Database>,
|
|
@@ -138,6 +153,7 @@ export function bindSeed<Database = unknown>(
|
|
|
138
153
|
return { ...definition, binding }
|
|
139
154
|
}
|
|
140
155
|
|
|
156
|
+
/** Type guard: `true` se o valor é uma declaração de seed válida. */
|
|
141
157
|
export function isSeedDefinition(value: unknown): value is SeedDefinition {
|
|
142
158
|
try {
|
|
143
159
|
validateDefinition(value)
|
|
@@ -147,6 +163,7 @@ export function isSeedDefinition(value: unknown): value is SeedDefinition {
|
|
|
147
163
|
}
|
|
148
164
|
}
|
|
149
165
|
|
|
166
|
+
/** Type guard: `true` se o valor é um seed declarado e com binding completo. */
|
|
150
167
|
export function isBoundSeed(value: unknown): value is BoundSeed {
|
|
151
168
|
if (!isSeedDefinition(value)) return false
|
|
152
169
|
try {
|
|
@@ -157,6 +174,10 @@ export function isBoundSeed(value: unknown): value is BoundSeed {
|
|
|
157
174
|
}
|
|
158
175
|
}
|
|
159
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Verifica o registro de seeds: duplicidade, binding ausente, dependência
|
|
179
|
+
* inexistente e ciclo. Devolve a ordem topológica quando não há ciclo.
|
|
180
|
+
*/
|
|
160
181
|
export function checkSeedRegistry(items: readonly SeedRegistryItem[]): SeedRegistryCheck {
|
|
161
182
|
const diagnostics: SeedDiagnostic[] = []
|
|
162
183
|
const byName = new Map<string, BoundSeed>()
|
|
@@ -237,6 +258,7 @@ export function checkSeedRegistry(items: readonly SeedRegistryItem[]): SeedRegis
|
|
|
237
258
|
}
|
|
238
259
|
}
|
|
239
260
|
|
|
261
|
+
/** Resolve o perfil a aplicar (`requested` ou `defaultProfile`); lança se não existir no seed. */
|
|
240
262
|
export function resolveSeedProfile(seed: SeedDefinition, requested?: string): string {
|
|
241
263
|
const profile = requested ?? seed.defaultProfile
|
|
242
264
|
if (!Object.hasOwn(seed.profiles, profile)) {
|
|
@@ -247,6 +269,10 @@ export function resolveSeedProfile(seed: SeedDefinition, requested?: string): st
|
|
|
247
269
|
return profile
|
|
248
270
|
}
|
|
249
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Falha fechado antes de abrir conexão: exige `NODE_ENV` de desenvolvimento ou
|
|
274
|
+
* teste, escopo informado e permitido por `safety.scopes`.
|
|
275
|
+
*/
|
|
250
276
|
export function assertSeedExecutionAllowed(
|
|
251
277
|
seed: SeedDefinition,
|
|
252
278
|
scope: string | undefined,
|
|
@@ -274,6 +300,7 @@ function runtimeNodeEnvironment(): string | undefined {
|
|
|
274
300
|
).process?.env?.NODE_ENV
|
|
275
301
|
}
|
|
276
302
|
|
|
303
|
+
/** Garante que o retorno de `plan` tem a forma `{ summary, operations }`. */
|
|
277
304
|
export function validateSeedPlan(value: unknown): SeedPlan {
|
|
278
305
|
if (!isRecord(value) || !isNonEmptyString(value.summary) || !Array.isArray(value.operations)) {
|
|
279
306
|
throw new Error('plan precisa retornar { summary, operations }')
|
|
@@ -284,6 +311,7 @@ export function validateSeedPlan(value: unknown): SeedPlan {
|
|
|
284
311
|
return value as unknown as SeedPlan
|
|
285
312
|
}
|
|
286
313
|
|
|
314
|
+
/** Garante que o retorno de `apply`/`verify` tem a forma `{ summary, metrics }` com números finitos. */
|
|
287
315
|
export function validateSeedReport(value: unknown): SeedReport {
|
|
288
316
|
if (!isRecord(value) || !isNonEmptyString(value.summary) || !isMetrics(value.metrics)) {
|
|
289
317
|
throw new Error('apply/verify precisa retornar { summary, metrics } com valores numéricos finitos')
|
|
@@ -291,6 +319,7 @@ export function validateSeedReport(value: unknown): SeedReport {
|
|
|
291
319
|
return value as unknown as SeedReport
|
|
292
320
|
}
|
|
293
321
|
|
|
322
|
+
/** Compara as métricas do relatório com `profiles[profile].expected`; lança listando as divergências. */
|
|
294
323
|
export function assertSeedExpectedMetrics(
|
|
295
324
|
seed: SeedDefinition,
|
|
296
325
|
profile: string,
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
RuntimeRef,
|
|
29
29
|
ServerAdapter,
|
|
30
30
|
} from '../../core/index.ts'
|
|
31
|
+
import { readPackageVersion } from '../../core/package-version.ts'
|
|
31
32
|
import {
|
|
32
33
|
toOpenAPISpec,
|
|
33
34
|
type OpenAPIInfo,
|
|
@@ -58,7 +59,8 @@ export interface FastifyServerOptions {
|
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Info do OpenAPI document (title, version, description). Quando ausente,
|
|
61
|
-
* usa
|
|
62
|
+
* usa `{ title: 'Opus API', version: <versão do SDK> }` — sobrescreva pra
|
|
63
|
+
* publicar a identidade da sua API.
|
|
62
64
|
*/
|
|
63
65
|
openapiInfo?: OpenAPIInfo
|
|
64
66
|
|
|
@@ -73,11 +75,12 @@ export interface FastifyServerOptions {
|
|
|
73
75
|
// Adapter factory
|
|
74
76
|
// =============================================================================
|
|
75
77
|
|
|
78
|
+
/** ServerAdapter que monta as actions como rotas numa instância Fastify existente. */
|
|
76
79
|
export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
|
|
77
80
|
const {
|
|
78
81
|
app,
|
|
79
82
|
apiPrefix = '/api',
|
|
80
|
-
openapiInfo = { title: '
|
|
83
|
+
openapiInfo = { title: 'Opus API', version: readPackageVersion() },
|
|
81
84
|
openapiServers,
|
|
82
85
|
traceContext = false,
|
|
83
86
|
} = options
|
|
@@ -34,7 +34,8 @@ import type {
|
|
|
34
34
|
RuntimeRef,
|
|
35
35
|
ServerAdapter,
|
|
36
36
|
} from '../../core/index.ts'
|
|
37
|
-
import { error } from '../../core/index.ts'
|
|
37
|
+
import { error, UNHANDLED_ERROR_MESSAGE } from '../../core/index.ts'
|
|
38
|
+
import { readPackageVersion } from '../../core/package-version.ts'
|
|
38
39
|
import {
|
|
39
40
|
toOpenAPISpec,
|
|
40
41
|
type OpenAPIInfo,
|
|
@@ -68,7 +69,8 @@ export interface NodeServerOptions {
|
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* Info do OpenAPI document (title, version, description). Quando ausente,
|
|
71
|
-
* usa
|
|
72
|
+
* usa `{ title: 'Opus API', version: <versão do SDK> }` — sobrescreva pra
|
|
73
|
+
* publicar a identidade da sua API.
|
|
72
74
|
*/
|
|
73
75
|
openapiInfo?: OpenAPIInfo
|
|
74
76
|
|
|
@@ -97,11 +99,12 @@ export interface NodeServerHandle {
|
|
|
97
99
|
|
|
98
100
|
type RouteFn = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>
|
|
99
101
|
|
|
102
|
+
/** ServerAdapter sobre `node:http` puro, sem framework; expõe `handler` pra plugar em qualquer servidor. */
|
|
100
103
|
export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
|
|
101
104
|
const {
|
|
102
105
|
apiPrefix = '/api',
|
|
103
106
|
endpoints,
|
|
104
|
-
openapiInfo = { title: '
|
|
107
|
+
openapiInfo = { title: 'Opus API', version: readPackageVersion() },
|
|
105
108
|
openapiServers,
|
|
106
109
|
traceContext = false,
|
|
107
110
|
} = options
|
|
@@ -164,7 +167,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
|
|
|
164
167
|
res,
|
|
165
168
|
400,
|
|
166
169
|
driverResult(
|
|
167
|
-
{ code: 'server.invalid_json', category: 'validation', message: '
|
|
170
|
+
{ code: 'server.invalid_json', category: 'validation', message: 'O corpo da requisição não é um JSON válido' },
|
|
168
171
|
action.name,
|
|
169
172
|
requestId,
|
|
170
173
|
),
|
|
@@ -205,7 +208,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
|
|
|
205
208
|
res,
|
|
206
209
|
500,
|
|
207
210
|
driverResult(
|
|
208
|
-
{ code: 'server.unhandled', category: 'internal', message: String(err) },
|
|
211
|
+
{ code: 'server.unhandled', category: 'internal', message: UNHANDLED_ERROR_MESSAGE, cause: String(err) },
|
|
209
212
|
deriveActionName(url.pathname, apiPrefix),
|
|
210
213
|
crypto.randomUUID(),
|
|
211
214
|
),
|
|
@@ -319,7 +322,7 @@ function driverResult(
|
|
|
319
322
|
|
|
320
323
|
function routeNotFound(pathname: string, apiPrefix: string): ActionResult<never> {
|
|
321
324
|
return driverResult(
|
|
322
|
-
{ code: 'server.route_not_found', category: 'not_found', message: `
|
|
325
|
+
{ code: 'server.route_not_found', category: 'not_found', message: `Rota não encontrada: ${pathname}` },
|
|
323
326
|
deriveActionName(pathname, apiPrefix),
|
|
324
327
|
crypto.randomUUID(),
|
|
325
328
|
)
|
package/src/server/index.ts
CHANGED
|
@@ -181,7 +181,9 @@ function serializeError(err: ActionError): Record<string, unknown> {
|
|
|
181
181
|
if (err.i18nParams !== undefined) out.i18nParams = err.i18nParams
|
|
182
182
|
if (err.field !== undefined) out.field = err.field
|
|
183
183
|
if (err.issues !== undefined) out.issues = err.issues
|
|
184
|
-
|
|
184
|
+
// Erro interno não leva `cause` para a wire: é o texto cru da exceção (driver, SQL), que
|
|
185
|
+
// pertence ao log do servidor, não à pessoa nem ao cliente.
|
|
186
|
+
if (err.cause !== undefined && err.category !== 'internal') out.cause = String(err.cause)
|
|
185
187
|
if (err.meta !== undefined) out.meta = err.meta
|
|
186
188
|
return out
|
|
187
189
|
}
|
|
@@ -29,6 +29,7 @@ function metaPathOf(filePath: string): string {
|
|
|
29
29
|
return `${filePath}.meta.json`
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** StorageAdapter que grava objetos no sistema de arquivos local, com metadados em `<arquivo>.meta.json`. */
|
|
32
33
|
export function fsStorage(options: FsStorageOptions): StorageAdapter {
|
|
33
34
|
const root = path.resolve(options.root)
|
|
34
35
|
const baseUrl = options.baseUrl?.replace(/\/+$/, '')
|
package/src/testing/index.ts
CHANGED
|
@@ -250,7 +250,7 @@ async function validate<T>(
|
|
|
250
250
|
throw error({
|
|
251
251
|
code: kind === 'input' ? 'validation.invalid_input' : 'validation.invalid_output',
|
|
252
252
|
category: kind === 'input' ? 'validation' : 'internal',
|
|
253
|
-
message: kind === 'input' ? '
|
|
253
|
+
message: kind === 'input' ? 'Dados de entrada inválidos' : 'Dados de saída inválidos (validação em dev)',
|
|
254
254
|
issues: result.issues.map((iss) => ({
|
|
255
255
|
path: (iss.path ?? []).join('.'),
|
|
256
256
|
code: 'invalid',
|
|
@@ -294,7 +294,7 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
294
294
|
throw error({
|
|
295
295
|
code: 'auth.unauthenticated',
|
|
296
296
|
category: 'authentication',
|
|
297
|
-
message: '
|
|
297
|
+
message: 'Autenticação necessária',
|
|
298
298
|
})
|
|
299
299
|
}
|
|
300
300
|
|
|
@@ -305,7 +305,7 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
305
305
|
? Boolean(evalExpression(parseExpression(spec), { user: ctx.user, input: parsed, ctx, ...(loaded ?? {}) }))
|
|
306
306
|
: await spec(ctx, parsed, loaded)
|
|
307
307
|
if (decision === false) {
|
|
308
|
-
throw error({ code: 'auth.forbidden', category: 'authorization', message: '
|
|
308
|
+
throw error({ code: 'auth.forbidden', category: 'authorization', message: 'Acesso negado' })
|
|
309
309
|
}
|
|
310
310
|
if (decision !== true) throw decision
|
|
311
311
|
}
|