@graphoria/server 0.2.0 → 0.2.2
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/dist/src/ai/agent/providers/ollama.d.ts.map +1 -1
- package/dist/src/ai/mcp/create-server.d.ts.map +1 -1
- package/dist/src/ai/mcp/index.d.ts +5 -2
- package/dist/src/ai/mcp/index.d.ts.map +1 -1
- package/dist/src/ai/tools/agent.d.ts.map +1 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/query-data.d.ts +2 -2
- package/dist/src/ai/tools/query-data.d.ts.map +1 -1
- package/dist/src/analyzeQuery/analyzers/fragmentAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/analyzers/operationAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/analyzers/variableAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/directiveUtils.d.ts.map +1 -1
- package/dist/src/analyzeQuery/resolveVariables.d.ts +17 -2
- package/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
- package/dist/src/analyzeQuery/typeUtils.d.ts.map +1 -1
- package/dist/src/analyzeQuery/valueExtractors.d.ts.map +1 -1
- package/dist/src/authentication/duration.d.ts.map +1 -1
- package/dist/src/authentication/index.d.ts.map +1 -1
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/tokenRepository.d.ts +2 -1
- package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
- package/dist/src/cli/seedAuth.d.ts.map +1 -1
- package/dist/src/cli/seedAuthArgs.d.ts.map +1 -1
- package/dist/src/config/helpers/cronHelper.d.ts.map +1 -1
- package/dist/src/config/helpers/operationHelper.d.ts +1 -1
- package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
- package/dist/src/config/helpers/queueHelper.d.ts.map +1 -1
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +11 -5
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/auth.test.d.ts +2 -0
- package/dist/src/config/types/auth.test.d.ts.map +1 -0
- package/dist/src/config/types/db.d.ts +36 -20
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +6 -6
- package/dist/src/config/types/queue.d.ts +4 -4
- package/dist/src/configuration/getSchemas/index.d.ts +109 -103
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +59 -50
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/buildExecute.d.ts.map +1 -1
- package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +100 -95
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/openApiErrors.d.ts.map +1 -1
- package/dist/src/configuration/rest/parseStringParams.d.ts +15 -0
- package/dist/src/configuration/rest/parseStringParams.d.ts.map +1 -0
- package/dist/src/configuration/rest/parseStringParams.test.d.ts +2 -0
- package/dist/src/configuration/rest/parseStringParams.test.d.ts.map +1 -0
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/databases/auth/auth-operations.d.ts.map +1 -1
- package/dist/src/databases/auth/password.d.ts.map +1 -1
- package/dist/src/databases/common.d.ts +7 -3
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/identifier.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +4 -3
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/directives.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/auth.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +13 -12
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getViews.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/auth.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +13 -12
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getViews.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/auth.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +13 -12
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getViews.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/shared/claims.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +11 -59
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/identifierMapping.test.d.ts +2 -0
- package/dist/src/databases/identifierMapping.test.d.ts.map +1 -0
- package/dist/src/databases/metadata/structure.d.ts +13 -12
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/generateCreateTable/index.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/generateCreateView/index.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/generateInsert/index.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/generateSchemaCreation/index.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/mssqlToPostgres/convertView.d.ts.map +1 -1
- package/dist/src/databases/schemaBuilder/utils.d.ts +2 -2
- package/dist/src/databases/schemaBuilder/utils.d.ts.map +1 -1
- package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +37 -34
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/databases/transformers/genResolverName.d.ts.map +1 -1
- package/dist/src/databases/transformers/graphqlName.d.ts +23 -0
- package/dist/src/databases/transformers/graphqlName.d.ts.map +1 -0
- package/dist/src/databases/transformers/graphqlName.test.d.ts +2 -0
- package/dist/src/databases/transformers/graphqlName.test.d.ts.map +1 -0
- package/dist/src/index.d.ts +6 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/index.d.ts.map +1 -1
- package/dist/src/queues/kafka.d.ts.map +1 -1
- package/dist/src/queues/rabbitmq.d.ts.map +1 -1
- package/dist/src/remoteREST/index.d.ts.map +1 -1
- package/dist/src/remoteREST/parse.d.ts.map +1 -1
- package/dist/src/remoteREST/proxy.d.ts.map +1 -1
- package/dist/src/remoteREST/transform.d.ts.map +1 -1
- package/dist/src/remoteSchemas/index.d.ts.map +1 -1
- package/dist/src/remoteSchemas/introspect.d.ts.map +1 -1
- package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
- package/dist/src/remoteSchemas/transform.d.ts.map +1 -1
- package/dist/src/singletons/ai.d.ts.map +1 -1
- package/dist/src/singletons/authentication.d.ts.map +1 -1
- package/dist/src/singletons/cache/lruCacheStore.d.ts.map +1 -1
- package/dist/src/singletons/cache/redisCacheStore.d.ts.map +1 -1
- package/dist/src/singletons/cache/registry.d.ts.map +1 -1
- package/dist/src/singletons/cron.d.ts.map +1 -1
- package/dist/src/singletons/databases.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +191 -188
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queues.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/index.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/db.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +647 -636
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/resolver.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +18 -18
- package/dist/src/types/zod/configuration.d.ts +300 -295
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +89 -80
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/types/zod/queue.d.ts +80 -80
- package/dist/src/types/zod/queue.d.ts.map +1 -1
- package/dist/src/utils/benchmark.d.ts.map +1 -1
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/safeJSONParse.d.ts.map +1 -1
- package/dist/src/utils/selection.d.ts.map +1 -1
- package/dist/src/utils/sessionVariables.d.ts +5 -1
- package/dist/src/utils/sessionVariables.d.ts.map +1 -1
- package/dist/src/utils/writeSchema.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/playgrounds/console/index.html +18 -15
- package/playgrounds/graphiql/index.html +854 -622
- package/playgrounds/scalar/index.html +5 -5
- package/src/ai/README.md +257 -0
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +7 -1
- package/src/analyzeQuery/resolveVariables.ts +130 -16
- package/src/authentication/jwt.ts +3 -1
- package/src/authentication/paseto.ts +3 -1
- package/src/authentication/tokenRepository.ts +12 -4
- package/src/config/types/auth.ts +7 -1
- package/src/config/types/db.ts +8 -0
- package/src/configuration/getSchemas/mergeEntities/index.ts +26 -1
- package/src/configuration/getSchemas/type-definition-generator/index.ts +11 -6
- package/src/configuration/rest/handleRESTRequestFactory.ts +5 -7
- package/src/configuration/rest/parseStringParams.ts +92 -0
- package/src/databases/common.ts +13 -2
- package/src/databases/engines/mssql/query/index.ts +25 -12
- package/src/databases/engines/mysql/connection.ts +1 -0
- package/src/databases/engines/mysql/query/index.ts +21 -8
- package/src/databases/engines/postgresql/query/index.ts +21 -7
- package/src/databases/high-level-operations.ts +2 -2
- package/src/databases/transformers/genResolverName.ts +3 -3
- package/src/databases/transformers/graphqlName.ts +28 -0
- package/src/logging/index.ts +3 -1
- package/src/types/db.ts +0 -1
- package/src/types/zod/db.ts +5 -7
- package/src/types/zod/queue.ts +2 -10
- package/src/utils/sessionVariables.ts +7 -1
package/src/ai/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# ollama_helper
|
|
2
|
+
|
|
3
|
+
An LLM-powered database query agent that uses tool-calling to discover a PostgreSQL schema, inspect tables, and execute queries — all from a natural language prompt. It is **cross-LLM**: the same agent runs against Ollama (default, local), OpenAI, DeepSeek, Anthropic, and any OpenAI-compatible endpoint — selected with a single env var.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
User prompt
|
|
9
|
+
│
|
|
10
|
+
▼
|
|
11
|
+
┌─────────────────────────────────────────────────┐
|
|
12
|
+
│ createAgent({ tools, systemPrompt, wrap }) │
|
|
13
|
+
│ → (prompt) => Promise<string> │
|
|
14
|
+
│ │
|
|
15
|
+
│ Agent loop (ask function) │
|
|
16
|
+
│ 1. Send systemPrompt + wrap(prompt) to LLM │
|
|
17
|
+
│ 2. LLM responds with tool_calls? │
|
|
18
|
+
│ ├─ Yes → strip nulls, validate via Zod, │
|
|
19
|
+
│ │ execute tool, feed result back │
|
|
20
|
+
│ └─ No → return final answer │
|
|
21
|
+
│ 3. Repeat until answer or max iters │
|
|
22
|
+
└─────────────────────────────────────────────────┘
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
Final answer (text only — tool calls hidden)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The agent follows a fixed 4-step discovery workflow for every query:
|
|
29
|
+
|
|
30
|
+
| Step | Tool | Purpose |
|
|
31
|
+
| ---- | ----------------- | --------------------------------------------------- |
|
|
32
|
+
| 1 | `list_entities` | Find relevant database tables by keyword search |
|
|
33
|
+
| 2 | `describe_entity` | Get column names, types, and relationships |
|
|
34
|
+
| 3 | `query_data` | Execute the query using structured JSON (preferred) |
|
|
35
|
+
| 3\* | `graphql_execute` | Raw GraphQL fallback for complex queries |
|
|
36
|
+
| 4 | — | Present results as a Markdown table |
|
|
37
|
+
|
|
38
|
+
Anti-hallucination guards prevent the model from answering without actually querying data.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
**Prerequisites:** an LLM backend. The default is [Ollama](https://ollama.com) (local, free, no API key) running a tool-capable model. To use a hosted provider instead, set `LLM_PROVIDER` and the matching API key (see [Providers](#providers)).
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Install dependencies
|
|
46
|
+
bun install
|
|
47
|
+
|
|
48
|
+
# Run with the default provider (Ollama) and default prompt
|
|
49
|
+
bun run index.ts
|
|
50
|
+
|
|
51
|
+
# Custom prompt
|
|
52
|
+
bun run index.ts "how many students have an active subscription?"
|
|
53
|
+
|
|
54
|
+
# Switch providers via env vars
|
|
55
|
+
LLM_PROVIDER=openai LLM_MODEL=gpt-4o-mini OPENAI_API_KEY=sk-... bun run index.ts "list all events"
|
|
56
|
+
LLM_PROVIDER=deepseek LLM_MODEL=deepseek-chat DEEPSEEK_API_KEY=sk-... bun run index.ts "list all events"
|
|
57
|
+
LLM_PROVIDER=anthropic LLM_MODEL=claude-haiku-4-5-20251001 ANTHROPIC_API_KEY=sk-ant-... bun run index.ts "list all events"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Using as a library
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { createAgent } from "ollama_helper";
|
|
64
|
+
import { tools } from "./src/tools";
|
|
65
|
+
|
|
66
|
+
// Bind domain config once — call with just a prompt
|
|
67
|
+
const ask = createAgent({
|
|
68
|
+
tools,
|
|
69
|
+
systemPrompt: "You are a database assistant…",
|
|
70
|
+
wrap: (prompt) => `Database query:\n> ${prompt}`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const answer = await ask("list contacts grouped by role");
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Environment Variables
|
|
77
|
+
|
|
78
|
+
| Variable | Default | Description |
|
|
79
|
+
| ------------------- | ------------------------ | ----------------------------------------------------------- |
|
|
80
|
+
| `LLM_PROVIDER` | `ollama` | Backend: `ollama`, `openai`, `deepseek`, or `anthropic` |
|
|
81
|
+
| `LLM_MODEL` | per-provider | Overrides the provider's default model |
|
|
82
|
+
| `OPENAI_API_KEY` | — | Required when `LLM_PROVIDER=openai` |
|
|
83
|
+
| `OPENAI_BASE_URL` | — | Optional: any OpenAI-compatible endpoint (Groq, Mistral, …) |
|
|
84
|
+
| `DEEPSEEK_API_KEY` | — | Required when `LLM_PROVIDER=deepseek` |
|
|
85
|
+
| `ANTHROPIC_API_KEY` | — | Required when `LLM_PROVIDER=anthropic` |
|
|
86
|
+
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama server URL |
|
|
87
|
+
| `OLLAMA_MODEL` | `gemma4:e2b` | Ollama model (alias of `LLM_MODEL`, kept for back-compat) |
|
|
88
|
+
|
|
89
|
+
Defined in `.env` or inline on the command line.
|
|
90
|
+
|
|
91
|
+
## Providers
|
|
92
|
+
|
|
93
|
+
The agent talks to every backend through a small `Provider` interface (`src/providers/`). Two wire formats cover everything:
|
|
94
|
+
|
|
95
|
+
| Provider | `LLM_PROVIDER` | Default model | How it connects |
|
|
96
|
+
| ---------------- | -------------- | --------------------------- | ---------------------------------- |
|
|
97
|
+
| Ollama (default) | `ollama` | `gemma4:e2b` | Native `/api/chat`, local, no key |
|
|
98
|
+
| OpenAI | `openai` | `gpt-4o-mini` | `openai` SDK |
|
|
99
|
+
| DeepSeek | `deepseek` | `deepseek-chat` | `openai` SDK (OpenAI-compatible) |
|
|
100
|
+
| Anthropic | `anthropic` | `claude-haiku-4-5-20251001` | `@anthropic-ai/sdk` (Messages API) |
|
|
101
|
+
|
|
102
|
+
Because the OpenAI adapter is just a `baseURL` + key, **any OpenAI-compatible endpoint** (Groq, Mistral, Together, a local `/v1` server) works by pointing `OPENAI_BASE_URL` at it — no new code. Adding a first-class provider is one entry in the registry in `src/providers/index.ts`.
|
|
103
|
+
|
|
104
|
+
The agent loop and anti-hallucination guards are provider-agnostic; each adapter only translates the normalized message/tool-call history to and from its provider's wire format.
|
|
105
|
+
|
|
106
|
+
## Recommended Ollama Models
|
|
107
|
+
|
|
108
|
+
| Model | Size | Quality | Notes |
|
|
109
|
+
| -------------- | ------ | ------- | --------------------------------------------------------------------------------------------- |
|
|
110
|
+
| **gemma4:e2b** | 7.2 GB | ★★★★★ | **Default.** Native function-calling, built for agentic workflows. Google-recommended params. |
|
|
111
|
+
| gemma4:e4b | 9.6 GB | ★★★★★ | Stronger reasoning, heavier. Same family, better accuracy. |
|
|
112
|
+
| qwen3.5:2b | 2.7 GB | ★★★★☆ | Good budget option. Fast, decent tool use. |
|
|
113
|
+
| llama3.2 | 2.0 GB | ★★☆☆☆ | Works but hallucinates more. Use `OLLAMA_MODEL=llama3.2` to test. |
|
|
114
|
+
|
|
115
|
+
Each model family gets optimal sampling parameters automatically (temperature, top_p, top_k) based on vendor recommendations.
|
|
116
|
+
|
|
117
|
+
## Project Structure
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
ollama_helper/
|
|
121
|
+
├── index.ts # Library barrel — exports ask, createAgent, AgentConfig
|
|
122
|
+
├── .env # Environment variables (OLLAMA_MODEL, OLLAMA_HOST)
|
|
123
|
+
├── package.json
|
|
124
|
+
├── tsconfig.json
|
|
125
|
+
└── src/
|
|
126
|
+
├── types.ts # Normalized Message/ToolCall + Provider + Tool interfaces
|
|
127
|
+
├── providers/
|
|
128
|
+
│ ├── index.ts # getProvider() — selects the backend from LLM_PROVIDER
|
|
129
|
+
│ ├── ollama.ts # Native /api/chat adapter + model presets (default)
|
|
130
|
+
│ ├── openai.ts # OpenAI-compatible adapter (OpenAI, DeepSeek, …)
|
|
131
|
+
│ └── anthropic.ts # Anthropic Messages API adapter
|
|
132
|
+
├── tools.ts # 4 tool definitions (Zod schemas + mock executors)
|
|
133
|
+
└── agent.ts # Agent loop: ask() + createAgent() factory
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
### `createAgent(config: AgentConfig): (prompt: string) => Promise<string>`
|
|
139
|
+
|
|
140
|
+
Pre-configure an agent with tools, system prompt, and a prompt wrapper. Returns a single-argument function — call it with any prompt string.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { createAgent } from "ollama_helper";
|
|
144
|
+
|
|
145
|
+
const ask = createAgent({
|
|
146
|
+
tools: [...], // Tool[] — Zod-based tool definitions
|
|
147
|
+
systemPrompt: "You are a helpful agent.", // System-level instruction to the LLM
|
|
148
|
+
wrap: (p) => `User query:\n> ${p}`, // Wraps the raw prompt into the user message
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const answer = await ask("list all contacts grouped by role");
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `ask(prompt, tools, systemPrompt, wrap): Promise<string>`
|
|
155
|
+
|
|
156
|
+
Full-control entry point. Same as `createAgent` but without pre-binding — pass all 4 arguments per call.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { ask } from "ollama_helper";
|
|
160
|
+
|
|
161
|
+
const answer = await ask(
|
|
162
|
+
"list contacts grouped by role",
|
|
163
|
+
tools,
|
|
164
|
+
"You are a database assistant.",
|
|
165
|
+
(p) => `Query: ${p}`,
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `AgentConfig`
|
|
170
|
+
|
|
171
|
+
| Field | Type | Description |
|
|
172
|
+
| -------------- | ----------------------------- | ------------------------------------------------------ |
|
|
173
|
+
| `tools` | `Tool[]` | Zod-based tool definitions (schema + executor) |
|
|
174
|
+
| `systemPrompt` | `string` | System-level instruction sent to the LLM |
|
|
175
|
+
| `wrap` | `(content: string) => string` | Transforms the user prompt into the final user message |
|
|
176
|
+
|
|
177
|
+
### `Tool`
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
interface Tool<TSchema extends z.ZodTypeAny> {
|
|
181
|
+
name: string; // Tool name (must match what the LLM calls)
|
|
182
|
+
description: string; // Description sent to the LLM
|
|
183
|
+
schema: TSchema; // Zod schema — auto-converted to JSON Schema
|
|
184
|
+
execute: (args: z.infer<TSchema>) => Promise<unknown>; // Typed executor
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
JSON Schema is auto-generated via `z.toJSONSchema(tool.schema)`. The framework automatically strips `null` values from LLM arguments (common with small models) before Zod validation. Use the exported `jsonCoerce` helper for fields that may arrive as stringified JSON arrays/objects.
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { z } from "zod";
|
|
192
|
+
import { jsonCoerce } from "./src/tools";
|
|
193
|
+
|
|
194
|
+
const mySchema = z.object({
|
|
195
|
+
columns: jsonCoerce(z.array(z.string())).optional(), // Handles "[\"id\"]" → ["id"]
|
|
196
|
+
filters: jsonCoerce(z.record(z.string(), z.unknown())).optional(),
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Tools
|
|
201
|
+
|
|
202
|
+
The agent exposes 4 tools to the LLM. Each is defined as a `Tool` object with a Zod schema and executor:
|
|
203
|
+
|
|
204
|
+
| Tool | Description |
|
|
205
|
+
| ----------------- | -------------------------------------------------------------------------------- |
|
|
206
|
+
| `list_entities` | Search for database tables by keyword or category |
|
|
207
|
+
| `describe_entity` | Get column metadata, relationships, and data types for a table |
|
|
208
|
+
| `query_data` | Execute a query using structured JSON (preferred for all list/aggregate queries) |
|
|
209
|
+
| `graphql_execute` | Execute a raw GraphQL query (fallback for complex/nested queries) |
|
|
210
|
+
|
|
211
|
+
**`query_data` input format:**
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"entity": "contacts",
|
|
216
|
+
"operation": "aggregate",
|
|
217
|
+
"groupBy": ["role"],
|
|
218
|
+
"columns": ["id", "first_name", "last_name"],
|
|
219
|
+
"filters": { "deleted_at": { "is_null": true } },
|
|
220
|
+
"limit": 50
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Filter operators: `eq`, `neq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `is_null`.
|
|
225
|
+
|
|
226
|
+
## Anti-Hallucination Measures
|
|
227
|
+
|
|
228
|
+
The agent employs multiple layers to prevent the LLM from fabricating data:
|
|
229
|
+
|
|
230
|
+
1. **Zod validation** — All tool arguments validated before execution; invalid args → error fed back to LLM for self-correction
|
|
231
|
+
2. **Null stripping** — `null` values for optional fields (common LLM mistake) automatically stripped pre-parse
|
|
232
|
+
3. **JSON coercion** — Stringified JSON arrays/objects auto-parsed via `jsonCoerce` helper
|
|
233
|
+
4. **Temperature control** — Model-specific presets (e.g., temp 0 for llama3.2 to suppress creativity)
|
|
234
|
+
5. **Guard clause** — If the model tries to answer without calling `query_data` or `graphql_execute`, a corrective nudge is injected and the loop retries
|
|
235
|
+
6. **Negative constraints** — Prompts explicitly forbid fabrication: _"NEVER fabricate, invent, or guess query results"_
|
|
236
|
+
|
|
237
|
+
## Mock Tools
|
|
238
|
+
|
|
239
|
+
When a real Graphoria server is not available, the tools return hardcoded mock data mirroring the capoeira group management schema:
|
|
240
|
+
|
|
241
|
+
- **5 tables:** contacts, student_profiles, classes, payments, events
|
|
242
|
+
- **18 contacts** with roles: 12 students, 1 master, 2 guardians, 3 guests
|
|
243
|
+
- **Full column metadata** for the contacts table (11 columns including soft-delete `deleted_at`)
|
|
244
|
+
|
|
245
|
+
To replace mocks with real server calls, update the executors in `src/tools.ts` to use `fetch()` against the Graphoria REST/GraphQL endpoints.
|
|
246
|
+
|
|
247
|
+
## Design Decisions
|
|
248
|
+
|
|
249
|
+
- **`createAgent` factory** — Pre-bind domain config (tools, system prompt, wrap); clean `ask(prompt)` API for callers
|
|
250
|
+
- **`ask()` for full control** — 4 positional params when callers need per-call variations
|
|
251
|
+
- **Zod schemas for tools** — `z.toJSONSchema()` auto-generates JSON Schema parameters; `z.infer` gives type-safe executor args; no hand-written JSON Schema
|
|
252
|
+
- **`stripNulls` applied automatically** — LLMs pass `null` for optional fields; stripped pre-parse so callers write plain Zod schemas
|
|
253
|
+
- **`jsonCoerce` opt-in helper** — Small LLMs stringify arrays/objects; opt-in per-field coercion
|
|
254
|
+
- **Non-streaming only** (`stream: false`) — simpler loop logic; no need to buffer partial tool_calls across chunks
|
|
255
|
+
- **JSON query tool over raw GraphQL** — structured JSON has far fewer syntax failure modes for LLMs than raw GraphQL string construction
|
|
256
|
+
- **Max 10 iterations** — safety valve to prevent infinite tool-calling loops
|
|
257
|
+
- **Bun runtime** — native fetch, zero-config TypeScript, fast startup
|
|
@@ -59,7 +59,13 @@ export const analyzeSelections = (
|
|
|
59
59
|
(dir) => dir.name === "include" || dir.name === "skip" || dir.name === "when",
|
|
60
60
|
).length === 0;
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// Gated on the field resolving to a table type, not on it being a list.
|
|
63
|
+
// A role filter has to reach every field backed by the table it
|
|
64
|
+
// restricts: the `_single` root field and any to-one relationship both
|
|
65
|
+
// return one object, and both accept a `where` the builders honour, so
|
|
66
|
+
// skipping them let a caller read a row the filter excludes by asking
|
|
67
|
+
// for it singly or by traversing into it from an unfiltered table.
|
|
68
|
+
if (fieldObjType && entities.queriesMap[fieldName]?.rolePermission?.filter) {
|
|
63
69
|
if (!args) {
|
|
64
70
|
args = {
|
|
65
71
|
where: entities.queriesMap[fieldName].rolePermission?.filter,
|
|
@@ -72,6 +72,7 @@ export const extractRuntimePrimitivesToVariables = (
|
|
|
72
72
|
runtimeVariables: Record<string, unknown>,
|
|
73
73
|
startIndex: number,
|
|
74
74
|
referencedVariables: Set<string> = new Set(),
|
|
75
|
+
declaredVariableNames?: Set<string>,
|
|
75
76
|
): unknown => {
|
|
76
77
|
// Handle null/undefined
|
|
77
78
|
if (obj === null || obj === undefined) {
|
|
@@ -87,13 +88,23 @@ export const extractRuntimePrimitivesToVariables = (
|
|
|
87
88
|
runtimeVariables,
|
|
88
89
|
startIndex,
|
|
89
90
|
referencedVariables,
|
|
91
|
+
declaredVariableNames,
|
|
90
92
|
),
|
|
91
93
|
);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
// Handle primitives - convert to static variable references
|
|
95
97
|
if (typeof obj === "string") {
|
|
96
|
-
|
|
98
|
+
// A `$name` string is a reference only when the document declares that
|
|
99
|
+
// variable. These values arrive from the request body, so anything else
|
|
100
|
+
// beginning with `$` is data the client sent: `$1` resolved to index -1 and
|
|
101
|
+
// emitted the placeholder `$0`, and a value naming a real generated
|
|
102
|
+
// variable would have read that parameter's value instead of matching on
|
|
103
|
+
// its own text.
|
|
104
|
+
if (
|
|
105
|
+
obj.startsWith("$") &&
|
|
106
|
+
(!declaredVariableNames || declaredVariableNames.has(obj.substring(1)))
|
|
107
|
+
) {
|
|
97
108
|
referencedVariables.add(obj.substring(1));
|
|
98
109
|
return obj;
|
|
99
110
|
}
|
|
@@ -143,6 +154,7 @@ export const extractRuntimePrimitivesToVariables = (
|
|
|
143
154
|
runtimeVariables,
|
|
144
155
|
startIndex,
|
|
145
156
|
referencedVariables,
|
|
157
|
+
declaredVariableNames,
|
|
146
158
|
);
|
|
147
159
|
}
|
|
148
160
|
return result;
|
|
@@ -166,6 +178,7 @@ export const flattenObjectVariables = (
|
|
|
166
178
|
const resolvedObjectVarNames = new Set<string>();
|
|
167
179
|
const nestedReferencedVars = new Set<string>();
|
|
168
180
|
const resolvedMap = new Map<string, unknown>();
|
|
181
|
+
const declaredVariableNames = new Set(variables.map((variable) => variable.name));
|
|
169
182
|
|
|
170
183
|
for (const varDef of variables) {
|
|
171
184
|
if (varDef.name.startsWith("static_")) continue;
|
|
@@ -184,6 +197,7 @@ export const flattenObjectVariables = (
|
|
|
184
197
|
resolvedRuntimeValues,
|
|
185
198
|
existingStaticCount,
|
|
186
199
|
nestedReferencedVars,
|
|
200
|
+
declaredVariableNames,
|
|
187
201
|
);
|
|
188
202
|
|
|
189
203
|
resolvedMap.set(varDef.name, transformed);
|
|
@@ -200,15 +214,67 @@ export const flattenObjectVariables = (
|
|
|
200
214
|
|
|
201
215
|
// ─── Step 3: Immutable Field Resolution ─────────────────────────────────────
|
|
202
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Where late-resolved values are turned into bound parameters.
|
|
219
|
+
*
|
|
220
|
+
* Client-supplied argument values are hoisted into `static_N` variables while
|
|
221
|
+
* the document is analyzed, so by the time they reach the query builders they
|
|
222
|
+
* are `$name` references the builders emit as placeholders. Two classes of
|
|
223
|
+
* value never went through that hoisting, because they are not in the document:
|
|
224
|
+
* the constants written into a role's permission filter, and the claim values
|
|
225
|
+
* `$session.*` resolves to. Both were interpolated into the SQL text verbatim.
|
|
226
|
+
*/
|
|
227
|
+
export type VariableSink = {
|
|
228
|
+
generatedVariables: VariableDefinition[];
|
|
229
|
+
runtimeVariables: Record<string, unknown>;
|
|
230
|
+
startIndex: number;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const typeOfValue = (value: unknown): string => {
|
|
234
|
+
if (typeof value === "number") return Number.isInteger(value) ? "Int" : "Float";
|
|
235
|
+
if (typeof value === "boolean") return "Boolean";
|
|
236
|
+
return "String";
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Binds one value as a fresh `static_N` variable and returns its reference. */
|
|
240
|
+
const parameterize = (sink: VariableSink, value: unknown): unknown => {
|
|
241
|
+
if (value === null || value === undefined) return value;
|
|
242
|
+
|
|
243
|
+
// An array operand (`in`) is bound element-wise: the builders emit one
|
|
244
|
+
// placeholder per element.
|
|
245
|
+
if (Array.isArray(value)) return value.map((item) => parameterize(sink, item));
|
|
246
|
+
|
|
247
|
+
if (typeof value === "object") return value;
|
|
248
|
+
|
|
249
|
+
const name = `static_${sink.startIndex + sink.generatedVariables.length}`;
|
|
250
|
+
|
|
251
|
+
sink.generatedVariables.push({
|
|
252
|
+
name,
|
|
253
|
+
type: typeOfValue(value),
|
|
254
|
+
required: false,
|
|
255
|
+
defaultValue: value as never,
|
|
256
|
+
});
|
|
257
|
+
sink.runtimeVariables[name] = value;
|
|
258
|
+
|
|
259
|
+
return `$${name}`;
|
|
260
|
+
};
|
|
261
|
+
|
|
203
262
|
/**
|
|
204
263
|
* Replaces `$session.*` placeholders in any argument value.
|
|
205
264
|
* Walks all arguments, not just `where`.
|
|
265
|
+
*
|
|
266
|
+
* Substituted claim values are bound as parameters on the way out. Doing it
|
|
267
|
+
* here rather than in the `where` pass below matters: a claim whose value
|
|
268
|
+
* happens to begin with `$` would otherwise be indistinguishable from a
|
|
269
|
+
* variable reference, and would resolve to some other parameter's value.
|
|
206
270
|
*/
|
|
207
271
|
const resolveSessionInArguments = (
|
|
208
272
|
args: Record<string, unknown>,
|
|
209
273
|
session: SessionContext,
|
|
274
|
+
sink?: VariableSink,
|
|
210
275
|
): Record<string, unknown> => {
|
|
211
276
|
const result: Record<string, unknown> = {};
|
|
277
|
+
const onResolved = sink ? (value: unknown) => parameterize(sink, value) : undefined;
|
|
212
278
|
|
|
213
279
|
for (const [key, value] of Object.entries(args)) {
|
|
214
280
|
if (
|
|
@@ -217,7 +283,7 @@ const resolveSessionInArguments = (
|
|
|
217
283
|
!Array.isArray(value) &&
|
|
218
284
|
hasSessionVariables(value as Record<string, unknown>)
|
|
219
285
|
) {
|
|
220
|
-
result[key] = replaceSessionVariables(value as Record<string, unknown>, session);
|
|
286
|
+
result[key] = replaceSessionVariables(value as Record<string, unknown>, session, onResolved);
|
|
221
287
|
} else {
|
|
222
288
|
result[key] = value;
|
|
223
289
|
}
|
|
@@ -226,6 +292,33 @@ const resolveSessionInArguments = (
|
|
|
226
292
|
return result;
|
|
227
293
|
};
|
|
228
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Binds every literal still sitting in a `where` argument.
|
|
297
|
+
*
|
|
298
|
+
* Only `where` is walked. `orderBy` carries direction enums and `limit`/`offset`
|
|
299
|
+
* are consumed by the pagination builder, and neither is emitted as a bound
|
|
300
|
+
* value, so parameterizing them would change the SQL rather than protect it.
|
|
301
|
+
*/
|
|
302
|
+
const parameterizeWhereLiterals = (value: unknown, sink: VariableSink): unknown => {
|
|
303
|
+
if (value === null || value === undefined) return value;
|
|
304
|
+
|
|
305
|
+
if (Array.isArray(value)) return value.map((item) => parameterizeWhereLiterals(item, sink));
|
|
306
|
+
|
|
307
|
+
if (typeof value === "object") {
|
|
308
|
+
return Object.fromEntries(
|
|
309
|
+
Object.entries(value as Record<string, unknown>).map(([key, nested]) => [
|
|
310
|
+
key,
|
|
311
|
+
parameterizeWhereLiterals(nested, sink),
|
|
312
|
+
]),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Already a reference — the analyzer hoisted it out of the document.
|
|
317
|
+
if (isString(value) && value.startsWith("$")) return value;
|
|
318
|
+
|
|
319
|
+
return parameterize(sink, value);
|
|
320
|
+
};
|
|
321
|
+
|
|
229
322
|
/**
|
|
230
323
|
* Recursively replaces `$varName` references with their resolved (already-flattened) values from
|
|
231
324
|
* `resolvedMap`, walking nested objects/arrays so a reference nested inside an inline argument
|
|
@@ -268,6 +361,7 @@ export const resolveFieldArguments = (
|
|
|
268
361
|
fields: SelectionAnalysis[],
|
|
269
362
|
resolvedMap: Map<string, unknown>,
|
|
270
363
|
session?: SessionContext,
|
|
364
|
+
sink?: VariableSink,
|
|
271
365
|
): SelectionAnalysis[] => {
|
|
272
366
|
return fields.map((field) => {
|
|
273
367
|
let newArguments = field.arguments;
|
|
@@ -287,13 +381,19 @@ export const resolveFieldArguments = (
|
|
|
287
381
|
|
|
288
382
|
// Replace session variables in ALL argument values (not just where)
|
|
289
383
|
if (session) {
|
|
290
|
-
newArguments = resolveSessionInArguments(newArguments, session);
|
|
384
|
+
newArguments = resolveSessionInArguments(newArguments, session, sink);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Bind whatever literals remain in `where`. After the two passes above the
|
|
388
|
+
// only ones left are the constants a role filter was configured with.
|
|
389
|
+
if (sink && newArguments["where"] !== undefined) {
|
|
390
|
+
newArguments["where"] = parameterizeWhereLiterals(newArguments["where"], sink);
|
|
291
391
|
}
|
|
292
392
|
}
|
|
293
393
|
|
|
294
394
|
// Recurse into nested selections
|
|
295
395
|
const newSelections = field.selections
|
|
296
|
-
? resolveFieldArguments(field.selections, resolvedMap, session)
|
|
396
|
+
? resolveFieldArguments(field.selections, resolvedMap, session, sink)
|
|
297
397
|
: field.selections;
|
|
298
398
|
|
|
299
399
|
// Only create a new field object if something changed
|
|
@@ -381,15 +481,20 @@ export const resolveVariables = (
|
|
|
381
481
|
const hasVars = operation.variables && operation.variables.length > 0;
|
|
382
482
|
|
|
383
483
|
if (!hasVars) {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
:
|
|
484
|
+
// A document with no variables of its own can still carry a role filter,
|
|
485
|
+
// whose constants and session values both need binding.
|
|
486
|
+
const sink: VariableSink = {
|
|
487
|
+
generatedVariables: [],
|
|
488
|
+
runtimeVariables: {},
|
|
489
|
+
startIndex: 0,
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const fields = resolveFieldArguments(operation.fields, new Map(), session, sink);
|
|
388
493
|
|
|
389
494
|
return {
|
|
390
495
|
fields,
|
|
391
|
-
variables: operation.variables ?? [],
|
|
392
|
-
allVariables: { ...variables },
|
|
496
|
+
variables: [...(operation.variables ?? []), ...sink.generatedVariables],
|
|
497
|
+
allVariables: { ...variables, ...sink.runtimeVariables },
|
|
393
498
|
};
|
|
394
499
|
}
|
|
395
500
|
|
|
@@ -404,10 +509,15 @@ export const resolveVariables = (
|
|
|
404
509
|
);
|
|
405
510
|
|
|
406
511
|
// 3. Resolve field arguments (immutable)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
512
|
+
// Numbering continues past the statics flattening just produced, so the two
|
|
513
|
+
// passes cannot mint the same name.
|
|
514
|
+
const sink: VariableSink = {
|
|
515
|
+
generatedVariables: [],
|
|
516
|
+
runtimeVariables: {},
|
|
517
|
+
startIndex: existingStaticCount + flattenResult.newStaticVariables.length,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const fields = resolveFieldArguments(operation.fields, flattenResult.resolvedMap, session, sink);
|
|
411
521
|
|
|
412
522
|
// 4. Build final variable list and merged values
|
|
413
523
|
const { variables: resolvedVarDefs, allVariables } = buildFinalVariables(
|
|
@@ -416,9 +526,13 @@ export const resolveVariables = (
|
|
|
416
526
|
variables,
|
|
417
527
|
);
|
|
418
528
|
|
|
529
|
+
const finalVarDefs = resolvedVarDefs.length > 0 ? resolvedVarDefs : operation.variables!;
|
|
530
|
+
|
|
419
531
|
return {
|
|
420
532
|
fields,
|
|
421
|
-
|
|
422
|
-
|
|
533
|
+
// Appended, never reordered: the builders emit `$n` from a definition's
|
|
534
|
+
// index in this list, and the driver binds it from the same list.
|
|
535
|
+
variables: [...finalVarDefs, ...sink.generatedVariables],
|
|
536
|
+
allVariables: { ...allVariables, ...sink.runtimeVariables },
|
|
423
537
|
};
|
|
424
538
|
};
|
|
@@ -214,7 +214,9 @@ export const createJWTService = (
|
|
|
214
214
|
verifyTokenAndGetSession,
|
|
215
215
|
createTokenPair,
|
|
216
216
|
refreshAccessToken,
|
|
217
|
-
|
|
217
|
+
// The refresh lifetime is the longest a revocation has to outlive, so it is
|
|
218
|
+
// the TTL for a JTI the repository has not seen before.
|
|
219
|
+
revoke: (jti: string) => tokenRepository.revoke(jti, env.jwt.rtExpiresIn),
|
|
218
220
|
isRevoked: tokenRepository.isRevoked,
|
|
219
221
|
};
|
|
220
222
|
};
|
|
@@ -250,7 +250,9 @@ export const createPASETOService = (
|
|
|
250
250
|
verifyTokenAndGetSession,
|
|
251
251
|
createTokenPair,
|
|
252
252
|
refreshAccessToken,
|
|
253
|
-
|
|
253
|
+
// The refresh lifetime is the longest a revocation has to outlive, so it is
|
|
254
|
+
// the TTL for a JTI the repository has not seen before.
|
|
255
|
+
revoke: (jti: string) => tokenRepository.revoke(jti, env.jwt.rtExpiresIn),
|
|
254
256
|
isRevoked: tokenRepository.isRevoked,
|
|
255
257
|
};
|
|
256
258
|
};
|
|
@@ -6,7 +6,8 @@ import { logger } from "../logging";
|
|
|
6
6
|
export type TokenRepository = {
|
|
7
7
|
saveJti(jti: string, expiresIn: string): Promise<void>;
|
|
8
8
|
isTokenUsed(jti: string): Promise<boolean>;
|
|
9
|
-
revoke
|
|
9
|
+
/** `expiresIn` only applies to a JTI with no TTL of its own — see `revoke`. */
|
|
10
|
+
revoke(jti: string, expiresIn: string): Promise<void>;
|
|
10
11
|
isRevoked(jti: string): Promise<boolean>;
|
|
11
12
|
};
|
|
12
13
|
|
|
@@ -39,11 +40,18 @@ export const createTokenRepositoryWithClient = (client: TokenRepositoryClient):
|
|
|
39
40
|
}
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
const revoke = async (jti: string) => {
|
|
43
|
+
const revoke = async (jti: string, expiresIn: string) => {
|
|
43
44
|
try {
|
|
44
|
-
const [isUsed] = await client.hmget(jti, ["isUsed"]);
|
|
45
|
-
if (
|
|
45
|
+
const [isUsed, isRevoked] = await client.hmget(jti, ["isUsed", "isRevoked"]);
|
|
46
|
+
if (isRevoked === "true") return;
|
|
47
|
+
|
|
46
48
|
await client.hset(jti, { isRevoked: "true" });
|
|
49
|
+
|
|
50
|
+
// saveJti is the only writer of a TTL, so `isUsed` doubles as "this key
|
|
51
|
+
// already expires on its own". Without one the revocation record would
|
|
52
|
+
// outlive the token it revokes, forever — but overwriting an existing TTL
|
|
53
|
+
// would move a refresh token's expiry, so only a fresh key gets one.
|
|
54
|
+
if (isUsed !== "true") await client.expire(jti, parseDurationToMs(expiresIn) / 1000);
|
|
47
55
|
} catch (error) {
|
|
48
56
|
log.error({ err: error }, "failed to revoke JTI");
|
|
49
57
|
throw new Error("Token revocation failed");
|
package/src/config/types/auth.ts
CHANGED
|
@@ -64,11 +64,17 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
|
|
|
64
64
|
* holding more of the same. Operators are constrained to the implemented set so
|
|
65
65
|
* a typo fails at boot instead of evaluating to no condition, which would hand
|
|
66
66
|
* the role the whole table.
|
|
67
|
+
*
|
|
68
|
+
* `partialRecord`, not `record`: an enum-keyed `z.record` is exhaustive, and
|
|
69
|
+
* `z.unknown()` accepts `undefined`, so the plain spelling parses
|
|
70
|
+
* `{ eq: … }` into an object carrying all twelve operator keys. The extra keys
|
|
71
|
+
* are invisible to JSON.stringify but reach buildCondition through
|
|
72
|
+
* Object.entries, which then evaluates operators the filter never declared.
|
|
67
73
|
*/
|
|
68
74
|
export const FilterConditionZod: z.ZodType<Record<string, unknown>> = z.lazy(() =>
|
|
69
75
|
z.record(
|
|
70
76
|
z.string(),
|
|
71
|
-
z.union([z.
|
|
77
|
+
z.union([z.partialRecord(z.enum(FILTER_OPERATORS), z.unknown()), FilterConditionZod]),
|
|
72
78
|
),
|
|
73
79
|
);
|
|
74
80
|
|
package/src/config/types/db.ts
CHANGED
|
@@ -138,6 +138,14 @@ export const BunSQLConnectionOptionsZod = z.strictObject({
|
|
|
138
138
|
maxLifetime: z.number().nonnegative().default(3600),
|
|
139
139
|
/** Whether to use TLS/SSL for the connection */
|
|
140
140
|
tls: z.boolean().default(false),
|
|
141
|
+
/**
|
|
142
|
+
* MySQL only: allow the client to fetch the server's RSA public key over a
|
|
143
|
+
* plain connection when caching_sha2_password asks for full authentication.
|
|
144
|
+
* Off by default, as it is in Bun: without TLS a man-in-the-middle can answer
|
|
145
|
+
* with its own key and read the password. Needed for MySQL 8 servers that are
|
|
146
|
+
* reachable only over plain TCP.
|
|
147
|
+
*/
|
|
148
|
+
allowPublicKeyRetrieval: z.boolean().default(false),
|
|
141
149
|
/** Automatic creation of prepared statements (default: true) */
|
|
142
150
|
prepare: z.boolean().default(true),
|
|
143
151
|
/** Return values outside i32 range as BigInts instead of strings (default: false) */
|