@softize/opus 8.6.6
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 +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tbdlib — AuditEmitter
|
|
3
|
+
*
|
|
4
|
+
* Classe que coordena a emissão de `AuditRecord` pros `AuditSink` registrados.
|
|
5
|
+
* Aplica config da action (redact, fields, severity, sink filter) antes de
|
|
6
|
+
* dispatchar; trata falhas conforme `auditMode` ('lenient' | 'strict').
|
|
7
|
+
*
|
|
8
|
+
* Ver §5 do protocolo.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { error } from './errors.ts'
|
|
12
|
+
import type {
|
|
13
|
+
AuditConfig,
|
|
14
|
+
AuditRecord,
|
|
15
|
+
AuditSink,
|
|
16
|
+
Logger,
|
|
17
|
+
} from './types.ts'
|
|
18
|
+
|
|
19
|
+
interface AuditEmitterOptions {
|
|
20
|
+
mode: 'lenient' | 'strict'
|
|
21
|
+
log: Logger
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AuditEmitter {
|
|
25
|
+
private readonly sinks: AuditSink[] = []
|
|
26
|
+
private readonly mode: 'lenient' | 'strict'
|
|
27
|
+
private readonly log: Logger
|
|
28
|
+
|
|
29
|
+
constructor(options: AuditEmitterOptions) {
|
|
30
|
+
this.mode = options.mode
|
|
31
|
+
this.log = options.log
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Registra um sink. Sinks são emitidos em paralelo; ordem de registro
|
|
36
|
+
* não importa.
|
|
37
|
+
*/
|
|
38
|
+
register(sink: AuditSink): void {
|
|
39
|
+
this.sinks.push(sink)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Emite um record pros sinks apropriados.
|
|
44
|
+
* - `config === false` → no-op (audit desabilitado pra esta action).
|
|
45
|
+
* - `config === true | undefined` → record bruto, todos os sinks.
|
|
46
|
+
* - `config: AuditConfig` → aplica redact/fields/severity/sink filter.
|
|
47
|
+
*
|
|
48
|
+
* Falhas seguem o `auditMode`:
|
|
49
|
+
* - `lenient` (default): warn no logger, processo segue.
|
|
50
|
+
* - `strict`: lança ActionError de categoria 'internal', aborta o flow.
|
|
51
|
+
*/
|
|
52
|
+
async emit(
|
|
53
|
+
record: AuditRecord,
|
|
54
|
+
config?: boolean | AuditConfig,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
if (config === false) return
|
|
57
|
+
|
|
58
|
+
const processed = this.applyConfig(record, config)
|
|
59
|
+
const targetSinks = this.filterSinks(config)
|
|
60
|
+
|
|
61
|
+
if (targetSinks.length === 0) return
|
|
62
|
+
|
|
63
|
+
const results = await Promise.allSettled(
|
|
64
|
+
targetSinks.map((sink) => Promise.resolve(sink.emit(processed))),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const failures = results.filter(
|
|
68
|
+
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if (failures.length === 0) return
|
|
72
|
+
|
|
73
|
+
if (this.mode === 'strict') {
|
|
74
|
+
throw error({
|
|
75
|
+
code: 'audit.sink.failed',
|
|
76
|
+
category: 'internal',
|
|
77
|
+
message: `${failures.length}/${targetSinks.length} audit sinks failed`,
|
|
78
|
+
meta: { failures: failures.map((f) => String(f.reason)) },
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.log.warn('audit sinks failed (lenient mode)', {
|
|
83
|
+
failures: failures.length,
|
|
84
|
+
total: targetSinks.length,
|
|
85
|
+
reasons: failures.map((f) => String(f.reason)),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Health check: ok se todos os sinks declararam ok (ou nenhum implementou
|
|
91
|
+
* healthCheck). Detalhes do estado de cada sink no `details`.
|
|
92
|
+
*/
|
|
93
|
+
async healthCheck(): Promise<{ ok: boolean; details: Record<string, unknown> }> {
|
|
94
|
+
const details: Record<string, unknown> = {}
|
|
95
|
+
let ok = true
|
|
96
|
+
|
|
97
|
+
for (const sink of this.sinks) {
|
|
98
|
+
if (sink.healthCheck === undefined) {
|
|
99
|
+
details[sink.name] = { ok: true, skipped: true }
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const result = await sink.healthCheck()
|
|
104
|
+
details[sink.name] = result
|
|
105
|
+
if (!result.ok) ok = false
|
|
106
|
+
} catch (err) {
|
|
107
|
+
details[sink.name] = { ok: false, error: String(err) }
|
|
108
|
+
ok = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { ok, details }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ===========================================================================
|
|
116
|
+
// Internos
|
|
117
|
+
// ===========================================================================
|
|
118
|
+
|
|
119
|
+
private applyConfig(
|
|
120
|
+
record: AuditRecord,
|
|
121
|
+
config: boolean | AuditConfig | undefined,
|
|
122
|
+
): AuditRecord {
|
|
123
|
+
if (config === undefined || config === true) return record
|
|
124
|
+
/* v8 ignore next 2 — defensivo; emit() já short-circuita config=false */
|
|
125
|
+
if (config === false) return record
|
|
126
|
+
|
|
127
|
+
let input = record.input
|
|
128
|
+
if (config.fields !== undefined) input = pickFields(input, config.fields)
|
|
129
|
+
if (config.redact !== undefined) input = redactPaths(input, config.redact)
|
|
130
|
+
|
|
131
|
+
let output: unknown = record.output
|
|
132
|
+
let suppressOutput = false
|
|
133
|
+
if (config.output === false) {
|
|
134
|
+
suppressOutput = true
|
|
135
|
+
} else if (typeof config.output === 'object' && record.output !== undefined) {
|
|
136
|
+
if (config.output.fields !== undefined)
|
|
137
|
+
output = pickFields(record.output, config.output.fields)
|
|
138
|
+
if (config.output.redact !== undefined)
|
|
139
|
+
output = redactPaths(output, config.output.redact)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { output: _stripped, ...rest } = record
|
|
143
|
+
const next: AuditRecord = { ...rest, input }
|
|
144
|
+
if (!suppressOutput && output !== undefined) next.output = output
|
|
145
|
+
if (config.severity !== undefined) next.severity = config.severity
|
|
146
|
+
|
|
147
|
+
return next
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private filterSinks(config: boolean | AuditConfig | undefined): AuditSink[] {
|
|
151
|
+
if (config === undefined || config === true || config === false) {
|
|
152
|
+
return this.sinks
|
|
153
|
+
}
|
|
154
|
+
if (config.sink === undefined) return this.sinks
|
|
155
|
+
return this.sinks.filter((sink) => sink.name === config.sink)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// =============================================================================
|
|
160
|
+
// Path helpers
|
|
161
|
+
// =============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Retorna um novo objeto contendo apenas as chaves listadas em `fields`.
|
|
165
|
+
* Suporta dot-paths simples ("payment.amount"); arrays não são percorridos.
|
|
166
|
+
*/
|
|
167
|
+
function pickFields(value: unknown, fields: string[]): unknown {
|
|
168
|
+
if (!isPlainObject(value)) return value
|
|
169
|
+
const result: Record<string, unknown> = {}
|
|
170
|
+
for (const field of fields) {
|
|
171
|
+
const found = getAtPath(value, field)
|
|
172
|
+
if (found.exists) {
|
|
173
|
+
setAtPath(result, field, found.value)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Substitui valores nos paths informados por '[REDACTED]'.
|
|
181
|
+
* Não mutaiona o original.
|
|
182
|
+
*/
|
|
183
|
+
function redactPaths(value: unknown, paths: string[]): unknown {
|
|
184
|
+
if (!isPlainObject(value)) return value
|
|
185
|
+
const cloned = deepClone(value)
|
|
186
|
+
for (const path of paths) {
|
|
187
|
+
const found = getAtPath(cloned, path)
|
|
188
|
+
if (found.exists) setAtPath(cloned, path, '[REDACTED]')
|
|
189
|
+
}
|
|
190
|
+
return cloned
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getAtPath(
|
|
194
|
+
obj: unknown,
|
|
195
|
+
path: string,
|
|
196
|
+
): { exists: boolean; value: unknown } {
|
|
197
|
+
const parts = path.split('.')
|
|
198
|
+
let cursor: unknown = obj
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
if (!isPlainObject(cursor) || !(part in cursor)) {
|
|
201
|
+
return { exists: false, value: undefined }
|
|
202
|
+
}
|
|
203
|
+
cursor = (cursor as Record<string, unknown>)[part]
|
|
204
|
+
}
|
|
205
|
+
return { exists: true, value: cursor }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function setAtPath(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
209
|
+
const parts = path.split('.')
|
|
210
|
+
let cursor: Record<string, unknown> = obj
|
|
211
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
212
|
+
const part = parts[i] as string
|
|
213
|
+
const next = cursor[part]
|
|
214
|
+
if (!isPlainObject(next)) {
|
|
215
|
+
const fresh: Record<string, unknown> = {}
|
|
216
|
+
cursor[part] = fresh
|
|
217
|
+
cursor = fresh
|
|
218
|
+
} else {
|
|
219
|
+
cursor = next as Record<string, unknown>
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
cursor[parts[parts.length - 1] as string] = value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
226
|
+
return (
|
|
227
|
+
typeof value === 'object' &&
|
|
228
|
+
value !== null &&
|
|
229
|
+
!Array.isArray(value) &&
|
|
230
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deepClone<T>(value: T): T {
|
|
235
|
+
if (typeof structuredClone === 'function') {
|
|
236
|
+
return structuredClone(value)
|
|
237
|
+
}
|
|
238
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
239
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/core — `defineContract` + `bindAction` (split contrato/handler).
|
|
3
|
+
*
|
|
4
|
+
* O `defineAction` junta o **contrato** (declarativo) com o **handler**
|
|
5
|
+
* (server-only) num objeto só — o que impede o frontend de importar a action
|
|
6
|
+
* sem arrastar db/segredos. `defineContract`/`bindAction` partem isso:
|
|
7
|
+
*
|
|
8
|
+
* - **Contrato** (roda nos dois lados → `shared`): identidade, input/output,
|
|
9
|
+
* fields/filters/projection, docs, e a regra `authorize` action-level.
|
|
10
|
+
* - **Binding** (server-only → `api`): `handler`, `loads`, `background`,
|
|
11
|
+
* `emits`, `idempotency`, e um `authorize` row-level que sobrescreve.
|
|
12
|
+
*
|
|
13
|
+
* O `bindAction(contract, binding)` produz um `ActionDef` normal — o runtime
|
|
14
|
+
* nem sabe que houve split. Ver §15 do protocolo / `docs/data-layer.md`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
ActionContext,
|
|
19
|
+
ActionDef,
|
|
20
|
+
AuthorizeSpec,
|
|
21
|
+
BackgroundConfig,
|
|
22
|
+
FormAction,
|
|
23
|
+
InferOutput,
|
|
24
|
+
LoadsSpec,
|
|
25
|
+
Paginated,
|
|
26
|
+
ProgressReporter,
|
|
27
|
+
ListAction,
|
|
28
|
+
SimpleAction,
|
|
29
|
+
ViewAction,
|
|
30
|
+
} from './types.ts'
|
|
31
|
+
|
|
32
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Contrato = ActionDef sem os campos de execução
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/** Campos server-only que o `bindAction` fornece — o resto é contrato. */
|
|
39
|
+
type BindingKeys = 'handler' | 'loads' | 'background' | 'emits' | 'idempotency'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `ParsedIn` (o lado PARSEADO do schema de input — defaults aplicados) defaulta
|
|
43
|
+
* a `any` nos contratos, de propósito: quem anota contrato à mão é o CLIENTE
|
|
44
|
+
* (`FormContract<MeuInput, MeuOutput>` em hooks/props), que só conhece o fio —
|
|
45
|
+
* e `authorize`/`mockHandler` são contravariantes em `ParsedIn`, então um
|
|
46
|
+
* default estreito rejeitaria contrato real cujo schema tem `.default()`.
|
|
47
|
+
* Quem precisa do parseado com precisão (`bindAction`) o infere do schema.
|
|
48
|
+
*/
|
|
49
|
+
export type SimpleContract<In = unknown, Out = unknown, ParsedIn = any> = Omit<
|
|
50
|
+
SimpleAction<In, Out, ParsedIn>,
|
|
51
|
+
BindingKeys
|
|
52
|
+
>
|
|
53
|
+
export type FormContract<
|
|
54
|
+
In extends Record<string, unknown> = Record<string, unknown>,
|
|
55
|
+
Out = unknown,
|
|
56
|
+
ParsedIn = any,
|
|
57
|
+
> = Omit<FormAction<In, Out, ParsedIn>, BindingKeys>
|
|
58
|
+
export type ListContract<In = unknown, Out = unknown, ParsedIn = any> = Omit<
|
|
59
|
+
ListAction<In, Out, ParsedIn>,
|
|
60
|
+
BindingKeys
|
|
61
|
+
>
|
|
62
|
+
export type ViewContract<In = unknown, Out = unknown, ParsedIn = any> = Omit<
|
|
63
|
+
ViewAction<In, Out, ParsedIn>,
|
|
64
|
+
BindingKeys
|
|
65
|
+
>
|
|
66
|
+
|
|
67
|
+
export type ActionContract<In = any, Out = any, ParsedIn = any> =
|
|
68
|
+
| SimpleContract<In, Out, ParsedIn>
|
|
69
|
+
| FormContract<In & Record<string, unknown>, Out, ParsedIn>
|
|
70
|
+
| ListContract<In, Out, ParsedIn>
|
|
71
|
+
| ViewContract<In, Out, ParsedIn>
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parte server-only amarrada por `bindAction`. `authorize` aqui (row-level)
|
|
75
|
+
* sobrescreve a do contrato. O retorno do handler aceita `Paginated<Out>` pra
|
|
76
|
+
* cobrir o kind `search` (cujo `output` descreve o item, não o envelope).
|
|
77
|
+
*
|
|
78
|
+
* Tudo aqui roda PÓS-validação — o runtime valida o input antes de executar —
|
|
79
|
+
* então o binding é tipado por `ParsedIn` (o output do schema de input,
|
|
80
|
+
* defaults aplicados), não pelo lado do fio.
|
|
81
|
+
*/
|
|
82
|
+
export interface ActionBinding<ParsedIn, Out> {
|
|
83
|
+
handler: (
|
|
84
|
+
ctx: ActionContext,
|
|
85
|
+
input: ParsedIn,
|
|
86
|
+
loaded?: Record<string, unknown>,
|
|
87
|
+
progress?: ProgressReporter,
|
|
88
|
+
) => Out | Paginated<Out> | Promise<Out | Paginated<Out>>
|
|
89
|
+
loads?: LoadsSpec<ParsedIn>
|
|
90
|
+
authorize?: AuthorizeSpec<ParsedIn>
|
|
91
|
+
background?: BackgroundConfig
|
|
92
|
+
emits?: string[]
|
|
93
|
+
idempotency?: (input: ParsedIn) => string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// defineContract (overloads por kind, igual defineAction)
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
export function defineContract<In, Out, ParsedIn = In>(
|
|
101
|
+
contract: SimpleContract<In, Out, ParsedIn>,
|
|
102
|
+
): SimpleContract<In, Out, ParsedIn>
|
|
103
|
+
export function defineContract<In extends Record<string, unknown>, Out, ParsedIn = In>(
|
|
104
|
+
contract: FormContract<In, Out, ParsedIn>,
|
|
105
|
+
): FormContract<In, Out, ParsedIn>
|
|
106
|
+
export function defineContract<In, Out, ParsedIn = In>(
|
|
107
|
+
contract: ListContract<In, Out, ParsedIn>,
|
|
108
|
+
): ListContract<In, Out, ParsedIn>
|
|
109
|
+
export function defineContract<In, Out, ParsedIn = In>(
|
|
110
|
+
contract: ViewContract<In, Out, ParsedIn>,
|
|
111
|
+
): ViewContract<In, Out, ParsedIn>
|
|
112
|
+
export function defineContract(contract: any): any {
|
|
113
|
+
return contract
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// bindAction
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Amarra o trabalho server-only a um contrato, produzindo um `ActionDef`.
|
|
122
|
+
* `binding.authorize` (row-level, com `loaded`) sobrescreve o `authorize`
|
|
123
|
+
* action-level do contrato.
|
|
124
|
+
*/
|
|
125
|
+
// O input do binding é o PARSEADO: extraído do lado de output do schema de
|
|
126
|
+
// input do contrato (`InferOutput`), porque o runtime valida antes de executar.
|
|
127
|
+
type ContractParsedIn<C extends ActionContract> = InferOutput<C['input']>
|
|
128
|
+
type ContractOut<C> = C extends ActionContract<any, infer Out, any> ? Out : never
|
|
129
|
+
|
|
130
|
+
export function bindAction<C extends ActionContract>(
|
|
131
|
+
contract: C,
|
|
132
|
+
// ParsedIn/Out são extraídos do contrato (C concreto), nunca do handler —
|
|
133
|
+
// senão o retorno `Paginated<Out>` do search contaminaria a inferência de `Out`.
|
|
134
|
+
binding: ActionBinding<ContractParsedIn<C>, ContractOut<C>>,
|
|
135
|
+
): ActionDef {
|
|
136
|
+
return { ...contract, ...binding } as unknown as ActionDef
|
|
137
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tbdlib — `defineDomain` factory + flatten helper.
|
|
3
|
+
*
|
|
4
|
+
* Domain agrupa as peças que pertencem a um mesmo recorte funcional
|
|
5
|
+
* (dicts, models, repository, service, actions, reactions, schedules,
|
|
6
|
+
* subdomains). É puramente declarativo: nenhum efeito colateral acontece
|
|
7
|
+
* em `defineDomain`. O runtime consome o objeto via `flattenDomain` quando
|
|
8
|
+
* recebe um `DomainConfig` em `register(...)`.
|
|
9
|
+
*
|
|
10
|
+
* Validações em runtime (em `defineDomain`):
|
|
11
|
+
* - `name` é string não-vazia no formato `[a-z][a-zA-Z0-9_]*`.
|
|
12
|
+
* - Actions têm nomes únicos dentro do domínio (incluindo subdomains).
|
|
13
|
+
* - Subdomains têm nomes únicos entre siblings.
|
|
14
|
+
*
|
|
15
|
+
* Warnings (em `Runtime.register`, via `collectDomainWarnings`):
|
|
16
|
+
* - Actions cujo `name` não casa com `<root>(\.<sub>)*\.<verb>` emitem
|
|
17
|
+
* `console.warn`. Heurística que depende do path completo da raiz —
|
|
18
|
+
* por isso fica pro register, não pro `defineDomain` (que pode rodar
|
|
19
|
+
* standalone como subdomain).
|
|
20
|
+
*
|
|
21
|
+
* Ver §17 do protocolo (TBD em v2).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ActionContract } from './contracts.ts'
|
|
25
|
+
import { error } from './errors.ts'
|
|
26
|
+
import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
27
|
+
|
|
28
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Tipos
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
|
|
36
|
+
* contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
|
|
37
|
+
* fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
|
|
38
|
+
* `mockHandler` responde. Fora de design, contrato sem handler falha no
|
|
39
|
+
* `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
|
|
40
|
+
*/
|
|
41
|
+
export type DomainAction = ActionDef | ActionContract
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Especificação declarativa de um domínio. Todos os campos exceto `name`
|
|
45
|
+
* são opcionais — domínio mínimo é só um `name`.
|
|
46
|
+
*/
|
|
47
|
+
export interface DomainConfig {
|
|
48
|
+
/** Nome do domínio (ex: `tickets`, `shipments`). camelCase. */
|
|
49
|
+
name: string
|
|
50
|
+
|
|
51
|
+
/** Doc de negócio do domínio — o recorte que ele cobre. Flui pro manifest/lente. */
|
|
52
|
+
description?: string
|
|
53
|
+
|
|
54
|
+
/** Dicionários do domínio (ver `@softize/opus/schema`). Map de nome → instance. */
|
|
55
|
+
dicts?: Record<string, unknown>
|
|
56
|
+
|
|
57
|
+
/** Entidades do domínio (`defineEntity`). Map de nome → EntityConfig. É a fonte
|
|
58
|
+
* estrutural+negócio que vai pro manifest. `models` é o nome legado (alias). */
|
|
59
|
+
entities?: Record<string, unknown>
|
|
60
|
+
|
|
61
|
+
/** @deprecated Use `entities`. Mantido por compat — o runtime/gen lê os dois. */
|
|
62
|
+
models?: Record<string, unknown>
|
|
63
|
+
|
|
64
|
+
/** Repository class do domínio. */
|
|
65
|
+
repository?: unknown
|
|
66
|
+
|
|
67
|
+
/** Service class do domínio. */
|
|
68
|
+
service?: unknown
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Actions do domínio — cada item é `ActionDef` (bound) ou `ActionContract`
|
|
72
|
+
* (contrato puro, sem handler; ver `DomainAction`). Pode ser:
|
|
73
|
+
* - `Record<string, DomainAction>` — map nome → action
|
|
74
|
+
* - `DomainAction[]` — array
|
|
75
|
+
* - `Record<string, DomainAction[]>` — barrel imports (`import * as actions`)
|
|
76
|
+
*/
|
|
77
|
+
actions?: Record<string, DomainAction | DomainAction[]> | DomainAction[]
|
|
78
|
+
|
|
79
|
+
/** Reactions registradas no domínio. */
|
|
80
|
+
reactions?: Array<ReactionDef<any>>
|
|
81
|
+
|
|
82
|
+
/** Schedules registrados no domínio. */
|
|
83
|
+
schedules?: ScheduleDef[]
|
|
84
|
+
|
|
85
|
+
/** Subdomínios (recursivo). */
|
|
86
|
+
subdomains?: DomainConfig[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resultado de `flattenDomain` — arrays achatados prontos pra `Runtime.register`.
|
|
91
|
+
*/
|
|
92
|
+
export interface FlattenedDomain {
|
|
93
|
+
actions: ActionDef[]
|
|
94
|
+
reactions: Array<ReactionDef<any>>
|
|
95
|
+
schedules: ScheduleDef[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================================================
|
|
99
|
+
// defineDomain
|
|
100
|
+
// =============================================================================
|
|
101
|
+
|
|
102
|
+
const DOMAIN_NAME_RE = /^[a-z][a-zA-Z0-9_]*$/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Constrói e valida um `DomainConfig`. Validação roda em runtime; em sucesso
|
|
106
|
+
* retorna o próprio config (identity, pra preservar inferência).
|
|
107
|
+
*
|
|
108
|
+
* Erros são `ActionError` com `category: 'internal'`.
|
|
109
|
+
*
|
|
110
|
+
* Warnings de nomenclatura (action que não casa com `<domain>(.sub)*.verb`)
|
|
111
|
+
* **não** são emitidos aqui — eles dependem do path completo da raiz e
|
|
112
|
+
* portanto saem em `Runtime.register([domain])` (ver `collectDomainWarnings`).
|
|
113
|
+
*/
|
|
114
|
+
export function defineDomain(config: DomainConfig): DomainConfig {
|
|
115
|
+
validateDomain(config, [])
|
|
116
|
+
return config
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walk no domínio emitindo warnings (via `console.warn`) pra actions cujo
|
|
121
|
+
* `name` não casa com o path completo `<root>(.sub)*.verb`. Chamado pelo
|
|
122
|
+
* `Runtime.register` quando recebe um `DomainConfig`.
|
|
123
|
+
*/
|
|
124
|
+
export function collectDomainWarnings(domain: DomainConfig): void {
|
|
125
|
+
walkForWarnings(domain, [])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Heurística simples pra detectar `DomainConfig` numa lista mista em
|
|
130
|
+
* `Runtime.register([...])`. Considera domain quando tem `name` (string)
|
|
131
|
+
* **sem** `kind` (descarta `ActionDef`) e **sem** `on`/`handler` no shape
|
|
132
|
+
* de reaction. Schedules têm `name + cron/every + action`, mas `action`
|
|
133
|
+
* é string — domain pode ter `actions` (plural). Faz a checagem por
|
|
134
|
+
* exclusão.
|
|
135
|
+
*/
|
|
136
|
+
export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
137
|
+
if (typeof value !== 'object' || value === null) return false
|
|
138
|
+
const v = value as Record<string, unknown>
|
|
139
|
+
if (typeof v.name !== 'string') return false
|
|
140
|
+
// ActionDef tem `kind` literal ('simple' | 'form' | 'list' | 'view').
|
|
141
|
+
if (typeof v.kind === 'string') return false
|
|
142
|
+
// ReactionDef tem `on` + `handler`.
|
|
143
|
+
if ('on' in v && 'handler' in v) return false
|
|
144
|
+
// ScheduleDef tem `action` (string) + `cron`/`every`.
|
|
145
|
+
if (typeof v.action === 'string') return false
|
|
146
|
+
// Domain precisa de pelo menos um dos campos abaixo pra ser útil — o
|
|
147
|
+
// teste impede falso positivo em `{ name: 'x' }` solto que sobrou de
|
|
148
|
+
// outra coisa.
|
|
149
|
+
return (
|
|
150
|
+
'actions' in v ||
|
|
151
|
+
'reactions' in v ||
|
|
152
|
+
'schedules' in v ||
|
|
153
|
+
'subdomains' in v ||
|
|
154
|
+
'dicts' in v ||
|
|
155
|
+
'models' in v ||
|
|
156
|
+
'repository' in v ||
|
|
157
|
+
'service' in v
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Achata um domínio (e seus subdomains recursivamente) em arrays prontos
|
|
163
|
+
* pro `Runtime.register`.
|
|
164
|
+
*/
|
|
165
|
+
export function flattenDomain(domain: DomainConfig): FlattenedDomain {
|
|
166
|
+
const out: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
167
|
+
collect(domain, out)
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// =============================================================================
|
|
172
|
+
// Internos
|
|
173
|
+
// =============================================================================
|
|
174
|
+
|
|
175
|
+
function collect(domain: DomainConfig, out: FlattenedDomain): void {
|
|
176
|
+
for (const action of iterateActions(domain.actions)) {
|
|
177
|
+
out.actions.push(action)
|
|
178
|
+
}
|
|
179
|
+
if (domain.reactions !== undefined) {
|
|
180
|
+
for (const reaction of domain.reactions) out.reactions.push(reaction)
|
|
181
|
+
}
|
|
182
|
+
if (domain.schedules !== undefined) {
|
|
183
|
+
for (const schedule of domain.schedules) out.schedules.push(schedule)
|
|
184
|
+
}
|
|
185
|
+
if (domain.subdomains !== undefined) {
|
|
186
|
+
for (const sub of domain.subdomains) collect(sub, out)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Normaliza `actions` (record | array | record-of-array) em iterável plano
|
|
192
|
+
* de `ActionDef`.
|
|
193
|
+
*/
|
|
194
|
+
function* iterateActions(
|
|
195
|
+
actions: DomainConfig['actions'],
|
|
196
|
+
): IterableIterator<ActionDef> {
|
|
197
|
+
if (actions === undefined) return
|
|
198
|
+
// Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
|
|
199
|
+
// runtime — em design o `mockHandler` responde; fora de design, a ausência de
|
|
200
|
+
// handler falha no `execute` (intencional). O cast reconcilia o tipo neste
|
|
201
|
+
// boundary de normalização; ver `DomainAction`.
|
|
202
|
+
if (Array.isArray(actions)) {
|
|
203
|
+
for (const a of actions) yield a as ActionDef
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
for (const value of Object.values(actions)) {
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
for (const a of value) yield a as ActionDef
|
|
209
|
+
} else {
|
|
210
|
+
yield value as ActionDef
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Valida um domínio recursivamente. `path` é a trilha dos ancestrais
|
|
217
|
+
* (camelCase concatenado em mensagens de erro).
|
|
218
|
+
*/
|
|
219
|
+
function validateDomain(domain: DomainConfig, path: string[]): void {
|
|
220
|
+
// — name —
|
|
221
|
+
if (typeof domain.name !== 'string' || domain.name.length === 0) {
|
|
222
|
+
throw error({
|
|
223
|
+
code: 'domain.invalid_name',
|
|
224
|
+
category: 'internal',
|
|
225
|
+
message: `Domain "name" deve ser string não-vazia${path.length > 0 ? ` (em "${path.join('.')}")` : ''}`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
if (!DOMAIN_NAME_RE.test(domain.name)) {
|
|
229
|
+
throw error({
|
|
230
|
+
code: 'domain.invalid_name_format',
|
|
231
|
+
category: 'internal',
|
|
232
|
+
message: `Domain name "${domain.name}" deve casar com [a-z][a-zA-Z0-9_]*${path.length > 0 ? ` (em "${path.join('.')}")` : ''}`,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const fullPath = [...path, domain.name]
|
|
237
|
+
const fullPathStr = fullPath.join('.')
|
|
238
|
+
|
|
239
|
+
// — subdomains: nomes únicos entre siblings —
|
|
240
|
+
if (domain.subdomains !== undefined) {
|
|
241
|
+
const seen = new Set<string>()
|
|
242
|
+
for (const sub of domain.subdomains) {
|
|
243
|
+
if (typeof sub.name !== 'string' || sub.name.length === 0) {
|
|
244
|
+
// Recursão vai validar; ainda assim, evitar set com chave estranha.
|
|
245
|
+
validateDomain(sub, fullPath)
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
if (seen.has(sub.name)) {
|
|
249
|
+
throw error({
|
|
250
|
+
code: 'domain.duplicate_subdomain',
|
|
251
|
+
category: 'internal',
|
|
252
|
+
message: `Subdomain "${sub.name}" duplicado em "${fullPathStr}"`,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
seen.add(sub.name)
|
|
256
|
+
validateDomain(sub, fullPath)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// — actions: nomes únicos no domínio (e subdomínios) —
|
|
261
|
+
// (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
|
|
262
|
+
// primeiro, antes de detectar duplicatas cruzadas.)
|
|
263
|
+
const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [] }
|
|
264
|
+
collect(domain, collected)
|
|
265
|
+
|
|
266
|
+
const actionNames = new Set<string>()
|
|
267
|
+
for (const action of collected.actions) {
|
|
268
|
+
if (actionNames.has(action.name)) {
|
|
269
|
+
throw error({
|
|
270
|
+
code: 'domain.duplicate_action',
|
|
271
|
+
category: 'internal',
|
|
272
|
+
message: `Action "${action.name}" duplicada em "${fullPathStr}"`,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
actionNames.add(action.name)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Caminha a árvore emitindo warnings de nomenclatura. `path` é a trilha de
|
|
281
|
+
* ancestrais (excluindo o próprio `domain`).
|
|
282
|
+
*/
|
|
283
|
+
function walkForWarnings(domain: DomainConfig, path: string[]): void {
|
|
284
|
+
const fullPath = [...path, domain.name]
|
|
285
|
+
const fullPathStr = fullPath.join('.')
|
|
286
|
+
for (const action of iterateActions(domain.actions)) {
|
|
287
|
+
if (!actionNameMatchesPath(action.name, fullPath)) {
|
|
288
|
+
console.warn(
|
|
289
|
+
`[opus] Action "${action.name}" não casa com prefixo "${fullPathStr}." — considere renomear.`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (domain.subdomains !== undefined) {
|
|
294
|
+
for (const sub of domain.subdomains) walkForWarnings(sub, fullPath)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `true` se `actionName` começa com `<domain>.` ou `<domain>.<sub>.` etc,
|
|
300
|
+
* e ainda tem um segmento de verbo no final.
|
|
301
|
+
*/
|
|
302
|
+
function actionNameMatchesPath(actionName: string, path: string[]): boolean {
|
|
303
|
+
const parts = actionName.split('.')
|
|
304
|
+
// Precisa de pelo menos `domain.verb`.
|
|
305
|
+
if (parts.length < path.length + 1) return false
|
|
306
|
+
for (let i = 0; i < path.length; i++) {
|
|
307
|
+
if (parts[i] !== path[i]) return false
|
|
308
|
+
}
|
|
309
|
+
return true
|
|
310
|
+
}
|