@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,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/data/kysely — Kysely driver
|
|
3
|
+
*
|
|
4
|
+
* Implementa `DataAdapter` envolvendo uma instância `Kysely<DB>`.
|
|
5
|
+
* Expõe `ctx.db` tipado pros handlers e implementa `healthCheck()`
|
|
6
|
+
* via `SELECT 1`.
|
|
7
|
+
*
|
|
8
|
+
* Uso:
|
|
9
|
+
* import { Kysely, PostgresDialect } from 'kysely'
|
|
10
|
+
* import { kyselyData } from '@softize/opus/data/kysely'
|
|
11
|
+
*
|
|
12
|
+
* const db = new Kysely<DB>({ dialect: new PostgresDialect({ pool }) })
|
|
13
|
+
*
|
|
14
|
+
* createRuntime({
|
|
15
|
+
* data: kyselyData({ db }),
|
|
16
|
+
* ...
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* // No handler:
|
|
20
|
+
* handler: async (ctx) => {
|
|
21
|
+
* const deals = await (ctx.db as Kysely<DB>).selectFrom('deals')...
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomUUID } from 'node:crypto'
|
|
26
|
+
|
|
27
|
+
import { z } from 'zod'
|
|
28
|
+
import { defineAction, error, type ActionDef, type AuthorizeFn } from '../../core/index.ts'
|
|
29
|
+
import type { DataAdapter, HealthStatus, Paginated } from '../../core/index.ts'
|
|
30
|
+
import {
|
|
31
|
+
desiredColumns,
|
|
32
|
+
diffColumns,
|
|
33
|
+
entityTable,
|
|
34
|
+
entityPk,
|
|
35
|
+
entityRowSchema,
|
|
36
|
+
entityInsertSchema,
|
|
37
|
+
entityUpdateSchema,
|
|
38
|
+
resolveNaming,
|
|
39
|
+
type DriftFinding,
|
|
40
|
+
type EntityConfig,
|
|
41
|
+
type EntityRow,
|
|
42
|
+
type EntityInsert,
|
|
43
|
+
type EntityUpdate,
|
|
44
|
+
type NamingStrategy,
|
|
45
|
+
} from '../../schema/entity.ts'
|
|
46
|
+
import { type Kysely, sql } from 'kysely'
|
|
47
|
+
|
|
48
|
+
export interface KyselyDataOptions<DB> {
|
|
49
|
+
/** Instância Kysely já configurada com dialect e pool. */
|
|
50
|
+
db: Kysely<DB>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Query usada pelo healthCheck. Default `SELECT 1`.
|
|
54
|
+
* Útil customizar pra dialects exóticos ou pra checks mais profundos.
|
|
55
|
+
*/
|
|
56
|
+
healthQuery?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function kyselyData<DB>(options: KyselyDataOptions<DB>): DataAdapter {
|
|
60
|
+
const { db, healthQuery = 'SELECT 1' } = options
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: 'kysely',
|
|
64
|
+
kind: 'data',
|
|
65
|
+
|
|
66
|
+
contextExtension() {
|
|
67
|
+
return { db }
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
71
|
+
try {
|
|
72
|
+
const start = performance.now()
|
|
73
|
+
await sql.raw(healthQuery).execute(db)
|
|
74
|
+
const latencyMs = performance.now() - start
|
|
75
|
+
return { ok: true, details: { latencyMs: round(latencyMs) } }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { ok: false, details: { error: String(err) } }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async dispose() {
|
|
82
|
+
await db.destroy()
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function round(n: number): number {
|
|
88
|
+
return Math.round(n * 100) / 100
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// Repo tipado a partir de uma entidade
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
/** Params de busca: paginação offset + filtros de igualdade (v1). */
|
|
96
|
+
export interface ListParams {
|
|
97
|
+
limit?: number
|
|
98
|
+
offset?: number
|
|
99
|
+
/** Filtros de igualdade por coluna: `{ status: 'open' }`. */
|
|
100
|
+
where?: Record<string, unknown>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** CRUD tipado sobre uma entidade, projetado nos tipos do `defineEntity`. */
|
|
104
|
+
export interface Repo<E extends EntityConfig> {
|
|
105
|
+
insert(values: EntityInsert<E>): Promise<EntityRow<E>>
|
|
106
|
+
findById(id: string): Promise<EntityRow<E> | null>
|
|
107
|
+
update(id: string, patch: EntityUpdate<E>): Promise<EntityRow<E>>
|
|
108
|
+
/** Soft-delete (seta `deletedAt`) se a entidade tem `softDelete`; senão DELETE. */
|
|
109
|
+
remove(id: string): Promise<void>
|
|
110
|
+
/** Lista tudo, cru (exclui soft-deleted quando aplicável). */
|
|
111
|
+
all(): Promise<EntityRow<E>[]>
|
|
112
|
+
/** A listagem paginada (offset) com filtros de igualdade. Retorna `Paginated`. */
|
|
113
|
+
list(params?: ListParams): Promise<Paginated<EntityRow<E>>>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface KyselyRepoOptions {
|
|
117
|
+
/** Relógio injetável (testes). Default `() => new Date().toISOString()`. */
|
|
118
|
+
now?: () => string
|
|
119
|
+
/** Gerador de id pra pk auto. Default `randomUUID`. */
|
|
120
|
+
genId?: () => string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Constrói um `Repo` tipado sobre Kysely a partir de uma entidade. Casca fina —
|
|
125
|
+
* não é ORM: gera id (pk auto), preenche timestamps, respeita soft-delete,
|
|
126
|
+
* marshalla campo `t.json()` (objeto→jsonb, guiado pela declaração), e deixa o
|
|
127
|
+
* resto pro Kysely. Tipos vêm do `defineEntity` (`EntityRow/Insert/Update`).
|
|
128
|
+
*/
|
|
129
|
+
export function kyselyRepo<E extends EntityConfig>(
|
|
130
|
+
db: Kysely<unknown>,
|
|
131
|
+
entity: E,
|
|
132
|
+
options: KyselyRepoOptions = {},
|
|
133
|
+
): Repo<E> {
|
|
134
|
+
const table = entityTable(entity)
|
|
135
|
+
const pk = entityPk(entity)
|
|
136
|
+
const now = options.now ?? (() => new Date().toISOString())
|
|
137
|
+
const genId = options.genId ?? (() => randomUUID())
|
|
138
|
+
const hasTimestamps = entity.timestamps === true
|
|
139
|
+
const hasSoftDelete = entity.softDelete === true
|
|
140
|
+
// Campos t.json(): o repo serializa NA ESCRITA (insert/update). Sem isso, o pg
|
|
141
|
+
// até stringifica objeto plano sozinho — mas ARRAY vira literal de array PG
|
|
142
|
+
// (errado pra jsonb) sem quebrar typecheck. A declaração já sabe o tipo; a
|
|
143
|
+
// dupla ColumnType<unknown, string, never> + JSON.stringify na mão morre aqui.
|
|
144
|
+
// String passa DIRETO (quem já mandava pré-serializado segue valendo — sem
|
|
145
|
+
// double-encode na migração). Na leitura o pg devolve objeto — nada a fazer.
|
|
146
|
+
const jsonFields = Object.entries(entity.fields)
|
|
147
|
+
.filter(([, f]) => f.meta?.logicalType === 'json')
|
|
148
|
+
.map(([name]) => name)
|
|
149
|
+
const marshalJson = (row: Record<string, unknown>): Record<string, unknown> => {
|
|
150
|
+
for (const name of jsonFields) {
|
|
151
|
+
const v = row[name]
|
|
152
|
+
if (v !== undefined && v !== null && typeof v !== 'string') row[name] = JSON.stringify(v)
|
|
153
|
+
}
|
|
154
|
+
return row
|
|
155
|
+
}
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
|
+
const qb = db as Kysely<any>
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
async insert(values) {
|
|
161
|
+
const row: Record<string, unknown> = marshalJson({ ...(values as Record<string, unknown>) })
|
|
162
|
+
if (pk.auto && row[pk.name] === undefined) row[pk.name] = genId()
|
|
163
|
+
if (hasTimestamps) {
|
|
164
|
+
const ts = now()
|
|
165
|
+
row.createdAt = ts
|
|
166
|
+
row.updatedAt = ts
|
|
167
|
+
}
|
|
168
|
+
const inserted = await qb
|
|
169
|
+
.insertInto(table)
|
|
170
|
+
.values(row)
|
|
171
|
+
.returningAll()
|
|
172
|
+
.executeTakeFirstOrThrow()
|
|
173
|
+
return inserted as EntityRow<E>
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async findById(id) {
|
|
177
|
+
let q = qb.selectFrom(table).selectAll().where(pk.name, '=', id)
|
|
178
|
+
if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
|
|
179
|
+
const found = await q.executeTakeFirst()
|
|
180
|
+
return (found ?? null) as EntityRow<E> | null
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async update(id, patch) {
|
|
184
|
+
const values: Record<string, unknown> = marshalJson({ ...(patch as Record<string, unknown>) })
|
|
185
|
+
if (hasTimestamps) values.updatedAt = now()
|
|
186
|
+
const updated = await qb
|
|
187
|
+
.updateTable(table)
|
|
188
|
+
.set(values)
|
|
189
|
+
.where(pk.name, '=', id)
|
|
190
|
+
.returningAll()
|
|
191
|
+
.executeTakeFirstOrThrow()
|
|
192
|
+
return updated as EntityRow<E>
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async remove(id) {
|
|
196
|
+
if (hasSoftDelete) {
|
|
197
|
+
await qb.updateTable(table).set({ deletedAt: now() }).where(pk.name, '=', id).execute()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
await qb.deleteFrom(table).where(pk.name, '=', id).execute()
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async all() {
|
|
204
|
+
let q = qb.selectFrom(table).selectAll()
|
|
205
|
+
if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
|
|
206
|
+
const rows = await q.execute()
|
|
207
|
+
return rows as EntityRow<E>[]
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async list(params = {}) {
|
|
211
|
+
const limit = params.limit ?? 50
|
|
212
|
+
const offset = params.offset ?? 0
|
|
213
|
+
const applyFilters = (query: any): any => {
|
|
214
|
+
let out = hasSoftDelete ? query.where('deletedAt', 'is', null) : query
|
|
215
|
+
if (params.where !== undefined) {
|
|
216
|
+
for (const [col, value] of Object.entries(params.where)) {
|
|
217
|
+
out = out.where(col, '=', value)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const countRow = await applyFilters(
|
|
224
|
+
qb.selectFrom(table).select((eb: any) => eb.fn.countAll().as('count')),
|
|
225
|
+
).executeTakeFirstOrThrow()
|
|
226
|
+
const total = Number((countRow as { count: number | string | bigint }).count)
|
|
227
|
+
|
|
228
|
+
const items = await applyFilters(qb.selectFrom(table).selectAll())
|
|
229
|
+
.limit(limit)
|
|
230
|
+
.offset(offset)
|
|
231
|
+
.execute()
|
|
232
|
+
|
|
233
|
+
const next = offset + limit < total ? String(offset + limit) : null
|
|
234
|
+
return { items: items as EntityRow<E>[], cursor: { next }, total }
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// =============================================================================
|
|
240
|
+
// crudActions — gera as actions de CRUD a partir de uma entidade
|
|
241
|
+
// =============================================================================
|
|
242
|
+
|
|
243
|
+
export type CrudOp = 'view' | 'create' | 'update' | 'delete' | 'list'
|
|
244
|
+
|
|
245
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
246
|
+
|
|
247
|
+
/** Authorize por operação. Só as operações declaradas são geradas (fail-closed). */
|
|
248
|
+
export type CrudAuthorize = { [K in CrudOp]?: AuthorizeFn<any, any> }
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Gera as opus actions de CRUD a partir de uma entidade, com handlers backed por
|
|
252
|
+
* `kyselyRepo(ctx.db, entity)` e input/output derivados do `defineEntity`. Só
|
|
253
|
+
* gera as ops cujo `authorize` foi fornecido (fail-closed). Nomes seguem
|
|
254
|
+
* `<entity>.<verb>` (passam no `opus check`). Composição, não core — vive no
|
|
255
|
+
* driver Kysely (ver `docs/data-layer.md`, peça 7).
|
|
256
|
+
*/
|
|
257
|
+
export function crudActions<E extends EntityConfig>(
|
|
258
|
+
entity: E,
|
|
259
|
+
authorize: CrudAuthorize,
|
|
260
|
+
): Record<string, ActionDef> {
|
|
261
|
+
const name = entity.name
|
|
262
|
+
const pk = entityPk(entity)
|
|
263
|
+
const repoOf = (ctx: { db: unknown }): Repo<E> => kyselyRepo(ctx.db as Kysely<unknown>, entity)
|
|
264
|
+
const idInput = z.object({ [pk.name]: z.string() })
|
|
265
|
+
const labelFields = (): Record<string, { label: string }> => {
|
|
266
|
+
const f: Record<string, { label: string }> = {}
|
|
267
|
+
for (const key of Object.keys(entity.fields)) f[key] = { label: key }
|
|
268
|
+
return f
|
|
269
|
+
}
|
|
270
|
+
const out: Record<string, ActionDef> = {}
|
|
271
|
+
|
|
272
|
+
if (authorize.view !== undefined) {
|
|
273
|
+
out.view = defineAction({
|
|
274
|
+
name: `${name}.view`,
|
|
275
|
+
kind: 'view',
|
|
276
|
+
input: idInput,
|
|
277
|
+
output: entityRowSchema(entity),
|
|
278
|
+
authorize: authorize.view,
|
|
279
|
+
projection: [],
|
|
280
|
+
handler: async (ctx: any, input: any) => {
|
|
281
|
+
const row = await repoOf(ctx).findById(input[pk.name])
|
|
282
|
+
if (row === null) {
|
|
283
|
+
throw error({
|
|
284
|
+
code: `${name}.view.notFound`,
|
|
285
|
+
category: 'not_found',
|
|
286
|
+
message: `${name} não encontrado`,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
return row
|
|
290
|
+
},
|
|
291
|
+
} as any)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (authorize.create !== undefined) {
|
|
295
|
+
out.create = defineAction({
|
|
296
|
+
name: `${name}.create`,
|
|
297
|
+
kind: 'form',
|
|
298
|
+
input: entityInsertSchema(entity),
|
|
299
|
+
output: entityRowSchema(entity),
|
|
300
|
+
authorize: authorize.create,
|
|
301
|
+
fields: labelFields(),
|
|
302
|
+
handler: async (ctx: any, input: any) => repoOf(ctx).insert(input),
|
|
303
|
+
} as any)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (authorize.update !== undefined) {
|
|
307
|
+
out.update = defineAction({
|
|
308
|
+
name: `${name}.update`,
|
|
309
|
+
kind: 'form',
|
|
310
|
+
input: entityUpdateSchema(entity).extend({ [pk.name]: z.string() }),
|
|
311
|
+
output: entityRowSchema(entity),
|
|
312
|
+
authorize: authorize.update,
|
|
313
|
+
fields: labelFields(),
|
|
314
|
+
handler: async (ctx: any, input: any) => {
|
|
315
|
+
const { [pk.name]: id, ...patch } = input
|
|
316
|
+
return repoOf(ctx).update(id, patch as EntityUpdate<E>)
|
|
317
|
+
},
|
|
318
|
+
} as any)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (authorize.list !== undefined) {
|
|
322
|
+
out.list = defineAction({
|
|
323
|
+
name: `${name}.list`,
|
|
324
|
+
kind: 'list',
|
|
325
|
+
input: z.object({
|
|
326
|
+
limit: z.number().int().positive().optional(),
|
|
327
|
+
offset: z.number().int().nonnegative().optional(),
|
|
328
|
+
where: z.record(z.unknown()).optional(),
|
|
329
|
+
}),
|
|
330
|
+
output: z.object({
|
|
331
|
+
items: z.array(entityRowSchema(entity)),
|
|
332
|
+
cursor: z.object({ next: z.string().nullable() }),
|
|
333
|
+
total: z.number(),
|
|
334
|
+
}),
|
|
335
|
+
authorize: authorize.list,
|
|
336
|
+
handler: async (ctx: any, input: any) => repoOf(ctx).list(input),
|
|
337
|
+
} as any)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (authorize.delete !== undefined) {
|
|
341
|
+
out.delete = defineAction({
|
|
342
|
+
name: `${name}.delete`,
|
|
343
|
+
kind: 'simple',
|
|
344
|
+
input: idInput,
|
|
345
|
+
output: z.object({ ok: z.literal(true) }),
|
|
346
|
+
authorize: authorize.delete,
|
|
347
|
+
handler: async (ctx: any, input: any) => {
|
|
348
|
+
await repoOf(ctx).remove(input[pk.name])
|
|
349
|
+
return { ok: true as const }
|
|
350
|
+
},
|
|
351
|
+
} as any)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return out
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Drift-check: compara as entidades (`defineEntity`) com o schema real do banco
|
|
361
|
+
* via introspecção do Kysely. Read-only — não muta nada. Pensado pra rodar no
|
|
362
|
+
* `opus check` e falhar o build se a migration divergir do `defineEntity`.
|
|
363
|
+
*
|
|
364
|
+
* v1: nível-coluna (presença + nullable). O `naming` (default `snake`) mapeia os
|
|
365
|
+
* nomes desejados pra bater com a introspecção crua — o `CamelCasePlugin` do
|
|
366
|
+
* Kysely não toca introspecção. Ver `docs/data-layer.md`.
|
|
367
|
+
*/
|
|
368
|
+
export async function kyselyDriftCheck<DB>(
|
|
369
|
+
db: Kysely<DB>,
|
|
370
|
+
entities: EntityConfig[],
|
|
371
|
+
options: { naming?: NamingStrategy } = {},
|
|
372
|
+
): Promise<DriftFinding[]> {
|
|
373
|
+
const naming = options.naming ?? 'snake'
|
|
374
|
+
const map = resolveNaming(naming)
|
|
375
|
+
const tables = await db.introspection.getTables()
|
|
376
|
+
const byName = new Map(tables.map((meta) => [meta.name, meta]))
|
|
377
|
+
|
|
378
|
+
const findings: DriftFinding[] = []
|
|
379
|
+
for (const entity of entities) {
|
|
380
|
+
const table = map(entityTable(entity))
|
|
381
|
+
const meta = byName.get(table)
|
|
382
|
+
const actual =
|
|
383
|
+
meta === undefined
|
|
384
|
+
? null
|
|
385
|
+
: meta.columns.map((c) => ({ name: c.name, nullable: c.isNullable }))
|
|
386
|
+
findings.push(...diffColumns(table, desiredColumns(entity, naming), actual))
|
|
387
|
+
}
|
|
388
|
+
return findings
|
|
389
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/data — shared helpers/types for data drivers.
|
|
3
|
+
*
|
|
4
|
+
* Empty for now — drivers (kysely, drizzle) são thin wrappers que apenas
|
|
5
|
+
* implementam o contrato `DataAdapter` do core. Quando aparecer código
|
|
6
|
+
* compartilhado (helpers de transação, observability hooks, etc),
|
|
7
|
+
* mora aqui.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/data/readonly-pool — `readOnlyContextPool`: SQL escrito por LLM
|
|
3
|
+
* executado com as QUATRO defesas do padrão da casa (ADRs 0003/0007 do 1º app real),
|
|
4
|
+
* todas no BANCO — não em regex sobre o texto da query:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Pool com teto** — crie o `Pool` com `max` (e/ou use `maxConcurrent` aqui):
|
|
7
|
+
* query de LLM não esgota as conexões do app.
|
|
8
|
+
* 2. **Transação READ ONLY** — `BEGIN TRANSACTION READ ONLY`: escrita morre no
|
|
9
|
+
* servidor, qualquer que seja o SQL.
|
|
10
|
+
* 3. **`SET LOCAL ROLE` por transação** — o alcance é do contexto: com grants
|
|
11
|
+
* default-fechado, `sales_read` não enxerga `hr_employees.salary` nem por
|
|
12
|
+
* SELECT criativo. (Criar os roles/grants é migração sua; aqui só se assume.)
|
|
13
|
+
* 4. **Protocolo estendido contra multi-sentença** — o SQL do LLM roda SEMPRE
|
|
14
|
+
* com array de valores (protocolo estendido do PG, que recusa `;` composto)
|
|
15
|
+
* — `SELECT 1; DROP TABLE x` nem chega a executar.
|
|
16
|
+
*
|
|
17
|
+
* Saindo, `DISCARD ALL` devolve a conexão limpa ao pool (role/settings zerados);
|
|
18
|
+
* se a limpeza falhar, a conexão é DESTRUÍDA (release com erro), nunca reusada suja.
|
|
19
|
+
*
|
|
20
|
+
* Uso (o caso action `ai: true` que executa SQL livre):
|
|
21
|
+
*
|
|
22
|
+
* import { Pool } from 'pg'
|
|
23
|
+
* import { readOnlyContextPool } from '@softize/opus/data/readonly-pool'
|
|
24
|
+
*
|
|
25
|
+
* const bi = readOnlyContextPool({ pool: new Pool({ connectionString, max: 4 }) })
|
|
26
|
+
* const { rows } = await bi.query(sqlDoLlm, { role: roleFor(ctx), params: [] })
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Tipos estruturais (sem dep direta de `pg` — aceita pool-like)
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
/** Cliente dedicado obtido do pool (subset do `pg.PoolClient`). */
|
|
34
|
+
export interface ReadOnlyPoolClient {
|
|
35
|
+
query(text: string, values?: unknown[]): Promise<unknown>
|
|
36
|
+
/** `release(err)` com erro DESTRÓI a conexão em vez de devolvê-la ao pool. */
|
|
37
|
+
release(err?: Error | boolean): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Subset de `pg.Pool` que precisamos: só `connect()`. */
|
|
41
|
+
export interface ReadOnlyConnectPool {
|
|
42
|
+
connect(): Promise<ReadOnlyPoolClient>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReadOnlyContextPoolOptions {
|
|
46
|
+
/** Pool pg-compatível. Crie COM teto (`new Pool({ max })`) — 1ª defesa. */
|
|
47
|
+
pool: ReadOnlyConnectPool
|
|
48
|
+
/**
|
|
49
|
+
* Teto de queries simultâneas DESTE wrapper (as demais enfileiram). Além do
|
|
50
|
+
* `max` do pool: segura a fila aqui em vez de disputar conexão com o app.
|
|
51
|
+
*/
|
|
52
|
+
maxConcurrent?: number
|
|
53
|
+
/** `SET LOCAL statement_timeout` por transação (ms). Default 15000. */
|
|
54
|
+
statementTimeoutMs?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ReadOnlyQueryOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Role de ALCANCE da transação (`SET LOCAL ROLE`) — derive do contexto
|
|
60
|
+
* (ex.: `sales_read`). Sem role, a query roda com o alcance da conexão.
|
|
61
|
+
*/
|
|
62
|
+
role?: string
|
|
63
|
+
/** Parâmetros posicionais (`$1`, `$2`…). Sempre enviados (mesmo vazios) — 4ª defesa. */
|
|
64
|
+
params?: unknown[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resultado mínimo (shape do `pg.Result`). */
|
|
68
|
+
export interface ReadOnlyQueryResult {
|
|
69
|
+
rows: unknown[]
|
|
70
|
+
rowCount?: number | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Fábrica
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
// Identificador de role: sem quoting exótico, minúsculo/underscore, limite do PG.
|
|
78
|
+
const ROLE_RE = /^[a-z_][a-z0-9_]{0,62}$/
|
|
79
|
+
|
|
80
|
+
export function readOnlyContextPool(options: ReadOnlyContextPoolOptions): {
|
|
81
|
+
query(sql: string, opts?: ReadOnlyQueryOptions): Promise<ReadOnlyQueryResult>
|
|
82
|
+
} {
|
|
83
|
+
const { pool, maxConcurrent, statementTimeoutMs = 15_000 } = options
|
|
84
|
+
if (maxConcurrent !== undefined && (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)) {
|
|
85
|
+
throw new Error(`readOnlyContextPool: maxConcurrent inválido (${maxConcurrent}) — inteiro ≥ 1.`)
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1) {
|
|
88
|
+
throw new Error(`readOnlyContextPool: statementTimeoutMs inválido (${statementTimeoutMs}).`)
|
|
89
|
+
}
|
|
90
|
+
const acquireSlot = maxConcurrent === undefined ? null : makeSemaphore(maxConcurrent)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async query(sql, opts = {}) {
|
|
94
|
+
const { role, params = [] } = opts
|
|
95
|
+
// Valida ANTES de tomar conexão — role ruim nem toca o pool.
|
|
96
|
+
if (role !== undefined && !ROLE_RE.test(role)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`readOnlyContextPool: role "${role}" inválido (esperado /${ROLE_RE.source}/ — o alcance vem de roles seus, não de texto livre).`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const releaseSlot = acquireSlot === null ? null : await acquireSlot()
|
|
103
|
+
try {
|
|
104
|
+
const client = await pool.connect()
|
|
105
|
+
let inTx = false
|
|
106
|
+
try {
|
|
107
|
+
await client.query('BEGIN TRANSACTION READ ONLY')
|
|
108
|
+
inTx = true
|
|
109
|
+
// SET não aceita parâmetro — os dois valores são validados acima.
|
|
110
|
+
await client.query(`SET LOCAL statement_timeout = ${statementTimeoutMs}`)
|
|
111
|
+
if (role !== undefined) await client.query(`SET LOCAL ROLE "${role}"`)
|
|
112
|
+
// O SQL do LLM: SEMPRE com values → protocolo estendido → sem multi-sentença.
|
|
113
|
+
const result = (await client.query(sql, params)) as ReadOnlyQueryResult
|
|
114
|
+
await client.query('COMMIT')
|
|
115
|
+
inTx = false
|
|
116
|
+
return { rows: result.rows ?? [], rowCount: result.rowCount ?? null }
|
|
117
|
+
} finally {
|
|
118
|
+
// Limpeza best-effort; se falhar, release COM erro → a conexão morre
|
|
119
|
+
// em vez de voltar suja (com role/settings trocados) pro pool.
|
|
120
|
+
let cleanupError: Error | undefined
|
|
121
|
+
try {
|
|
122
|
+
if (inTx) await client.query('ROLLBACK')
|
|
123
|
+
await client.query('DISCARD ALL')
|
|
124
|
+
} catch (err) {
|
|
125
|
+
cleanupError = err instanceof Error ? err : new Error(String(err))
|
|
126
|
+
}
|
|
127
|
+
client.release(cleanupError)
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
releaseSlot?.()
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// Semáforo (fila FIFO, zero-dep)
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
function makeSemaphore(limit: number): () => Promise<() => void> {
|
|
141
|
+
let active = 0
|
|
142
|
+
const waiting: Array<() => void> = []
|
|
143
|
+
const release = (): void => {
|
|
144
|
+
active -= 1
|
|
145
|
+
const next = waiting.shift()
|
|
146
|
+
if (next !== undefined) next()
|
|
147
|
+
}
|
|
148
|
+
return function acquire(): Promise<() => void> {
|
|
149
|
+
if (active < limit) {
|
|
150
|
+
active += 1
|
|
151
|
+
return Promise.resolve(release)
|
|
152
|
+
}
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
waiting.push(() => {
|
|
155
|
+
active += 1
|
|
156
|
+
resolve(release)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/dsl/eval.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/dsl — Runtime evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Avalia AST contra um contexto (`{ user, input, loaded, ... }`) e retorna
|
|
5
|
+
* o resultado. Usado pra `authorize` (boolean) e em outros casos onde
|
|
6
|
+
* precisa rodar a expressão direto em JS.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AstNode } from './types.ts'
|
|
10
|
+
|
|
11
|
+
export type EvalContext = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
export function evalExpression(node: AstNode, ctx: EvalContext): unknown {
|
|
14
|
+
switch (node.kind) {
|
|
15
|
+
case 'literal':
|
|
16
|
+
return node.value
|
|
17
|
+
case 'array':
|
|
18
|
+
return node.items.map((i) => evalExpression(i, ctx))
|
|
19
|
+
case 'identifier':
|
|
20
|
+
return ctx[node.name]
|
|
21
|
+
case 'member': {
|
|
22
|
+
const obj = evalExpression(node.object, ctx)
|
|
23
|
+
if (obj === null || obj === undefined) return undefined
|
|
24
|
+
return (obj as Record<string, unknown>)[node.property]
|
|
25
|
+
}
|
|
26
|
+
case 'unary': {
|
|
27
|
+
const v = evalExpression(node.operand, ctx)
|
|
28
|
+
return node.op === 'NOT' ? !v : -(v as number)
|
|
29
|
+
}
|
|
30
|
+
case 'binary': {
|
|
31
|
+
// Curto-circuito pra AND/OR
|
|
32
|
+
if (node.op === 'AND') {
|
|
33
|
+
const left = evalExpression(node.left, ctx)
|
|
34
|
+
if (!left) return false
|
|
35
|
+
return Boolean(evalExpression(node.right, ctx))
|
|
36
|
+
}
|
|
37
|
+
if (node.op === 'OR') {
|
|
38
|
+
const left = evalExpression(node.left, ctx)
|
|
39
|
+
if (left) return true
|
|
40
|
+
return Boolean(evalExpression(node.right, ctx))
|
|
41
|
+
}
|
|
42
|
+
const left = evalExpression(node.left, ctx) as number | string | null
|
|
43
|
+
const right = evalExpression(node.right, ctx) as number | string | null
|
|
44
|
+
switch (node.op) {
|
|
45
|
+
case '==':
|
|
46
|
+
return left === right
|
|
47
|
+
case '!=':
|
|
48
|
+
return left !== right
|
|
49
|
+
case '<':
|
|
50
|
+
return (left as number) < (right as number)
|
|
51
|
+
case '<=':
|
|
52
|
+
return (left as number) <= (right as number)
|
|
53
|
+
case '>':
|
|
54
|
+
return (left as number) > (right as number)
|
|
55
|
+
case '>=':
|
|
56
|
+
return (left as number) >= (right as number)
|
|
57
|
+
case '+':
|
|
58
|
+
return (left as number) + (right as number)
|
|
59
|
+
case '-':
|
|
60
|
+
return (left as number) - (right as number)
|
|
61
|
+
case '*':
|
|
62
|
+
return (left as number) * (right as number)
|
|
63
|
+
case '/':
|
|
64
|
+
return (left as number) / (right as number)
|
|
65
|
+
/* v8 ignore next 2 */
|
|
66
|
+
default:
|
|
67
|
+
throw new Error(`DSL eval: unknown binary op ${node.op as string}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
case 'in': {
|
|
71
|
+
const v = evalExpression(node.left, ctx)
|
|
72
|
+
const arr = evalExpression(node.values, ctx) as unknown[]
|
|
73
|
+
const found = Array.isArray(arr) && arr.includes(v)
|
|
74
|
+
return node.negated ? !found : found
|
|
75
|
+
}
|
|
76
|
+
case 'between': {
|
|
77
|
+
const v = evalExpression(node.subject, ctx) as number
|
|
78
|
+
const lo = evalExpression(node.low, ctx) as number
|
|
79
|
+
const hi = evalExpression(node.high, ctx) as number
|
|
80
|
+
const within = v >= lo && v <= hi
|
|
81
|
+
return node.negated ? !within : within
|
|
82
|
+
}
|
|
83
|
+
case 'like': {
|
|
84
|
+
const v = evalExpression(node.subject, ctx)
|
|
85
|
+
const pat = evalExpression(node.pattern, ctx) as string
|
|
86
|
+
const matched = sqlLikeMatch(
|
|
87
|
+
String(v ?? ''),
|
|
88
|
+
pat,
|
|
89
|
+
node.caseInsensitive,
|
|
90
|
+
)
|
|
91
|
+
return node.negated ? !matched : matched
|
|
92
|
+
}
|
|
93
|
+
case 'isNull': {
|
|
94
|
+
const v = evalExpression(node.subject, ctx)
|
|
95
|
+
const isNullish = v === null || v === undefined
|
|
96
|
+
return node.negated ? !isNullish : isNullish
|
|
97
|
+
}
|
|
98
|
+
case 'call':
|
|
99
|
+
return callFunction(node.name, node.args.map((a) => evalExpression(a, ctx)))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// SQL LIKE matcher
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
function sqlLikeMatch(s: string, pat: string, ci: boolean): boolean {
|
|
108
|
+
const regexSrc = pat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.')
|
|
109
|
+
const regex = new RegExp(`^${regexSrc}$`, ci ? 'i' : undefined)
|
|
110
|
+
return regex.test(s)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// =============================================================================
|
|
114
|
+
// Built-in functions
|
|
115
|
+
// =============================================================================
|
|
116
|
+
|
|
117
|
+
function callFunction(name: string, args: unknown[]): unknown {
|
|
118
|
+
switch (name) {
|
|
119
|
+
case 'NOW':
|
|
120
|
+
return new Date().toISOString()
|
|
121
|
+
case 'TODAY':
|
|
122
|
+
return new Date().toISOString().slice(0, 10)
|
|
123
|
+
case 'LOWER':
|
|
124
|
+
return String(args[0] ?? '').toLowerCase()
|
|
125
|
+
case 'UPPER':
|
|
126
|
+
return String(args[0] ?? '').toUpperCase()
|
|
127
|
+
case 'LENGTH':
|
|
128
|
+
return String(args[0] ?? '').length
|
|
129
|
+
case 'COALESCE':
|
|
130
|
+
for (const a of args) {
|
|
131
|
+
if (a !== null && a !== undefined) return a
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`DSL eval: unknown function '${name}'`)
|
|
136
|
+
}
|