@graphoria/server 0.2.1 → 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.
Files changed (163) hide show
  1. package/dist/src/ai/agent/providers/ollama.d.ts.map +1 -1
  2. package/dist/src/ai/mcp/create-server.d.ts.map +1 -1
  3. package/dist/src/ai/mcp/index.d.ts +5 -2
  4. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  5. package/dist/src/ai/tools/agent.d.ts.map +1 -1
  6. package/dist/src/ai/tools/core.d.ts.map +1 -1
  7. package/dist/src/ai/tools/query-data.d.ts +2 -2
  8. package/dist/src/ai/tools/query-data.d.ts.map +1 -1
  9. package/dist/src/analyzeQuery/analyzers/fragmentAnalyzer.d.ts.map +1 -1
  10. package/dist/src/analyzeQuery/analyzers/operationAnalyzer.d.ts.map +1 -1
  11. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  12. package/dist/src/analyzeQuery/analyzers/variableAnalyzer.d.ts.map +1 -1
  13. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  14. package/dist/src/analyzeQuery/directiveUtils.d.ts.map +1 -1
  15. package/dist/src/analyzeQuery/resolveVariables.d.ts +17 -2
  16. package/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
  17. package/dist/src/analyzeQuery/typeUtils.d.ts.map +1 -1
  18. package/dist/src/analyzeQuery/valueExtractors.d.ts.map +1 -1
  19. package/dist/src/authentication/duration.d.ts.map +1 -1
  20. package/dist/src/authentication/index.d.ts.map +1 -1
  21. package/dist/src/authentication/jwt.d.ts.map +1 -1
  22. package/dist/src/authentication/paseto.d.ts.map +1 -1
  23. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  24. package/dist/src/cli/seedAuth.d.ts.map +1 -1
  25. package/dist/src/cli/seedAuthArgs.d.ts.map +1 -1
  26. package/dist/src/config/helpers/cronHelper.d.ts.map +1 -1
  27. package/dist/src/config/helpers/operationHelper.d.ts +1 -1
  28. package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
  29. package/dist/src/config/helpers/queueHelper.d.ts.map +1 -1
  30. package/dist/src/config/index.d.ts.map +1 -1
  31. package/dist/src/config/types/auth.d.ts +11 -5
  32. package/dist/src/config/types/auth.d.ts.map +1 -1
  33. package/dist/src/config/types/auth.test.d.ts +2 -0
  34. package/dist/src/config/types/auth.test.d.ts.map +1 -0
  35. package/dist/src/config/types/db.d.ts +36 -20
  36. package/dist/src/config/types/db.d.ts.map +1 -1
  37. package/dist/src/config/types/operation.d.ts +6 -6
  38. package/dist/src/config/types/queue.d.ts +4 -4
  39. package/dist/src/configuration/getSchemas/index.d.ts +103 -99
  40. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  41. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +50 -48
  42. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  43. package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
  44. package/dist/src/configuration/gql/buildExecute.d.ts.map +1 -1
  45. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  46. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  47. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  48. package/dist/src/configuration/index.d.ts +95 -91
  49. package/dist/src/configuration/index.d.ts.map +1 -1
  50. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  51. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  52. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  53. package/dist/src/configuration/rest/openApiErrors.d.ts.map +1 -1
  54. package/dist/src/configuration/rest/parseStringParams.d.ts.map +1 -1
  55. package/dist/src/console/api.d.ts.map +1 -1
  56. package/dist/src/cron/index.d.ts.map +1 -1
  57. package/dist/src/databases/auth/auth-operations.d.ts.map +1 -1
  58. package/dist/src/databases/auth/password.d.ts.map +1 -1
  59. package/dist/src/databases/common.d.ts +1 -3
  60. package/dist/src/databases/common.d.ts.map +1 -1
  61. package/dist/src/databases/core/executor.d.ts.map +1 -1
  62. package/dist/src/databases/core/identifier.d.ts.map +1 -1
  63. package/dist/src/databases/core/query-builder.d.ts +3 -2
  64. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  65. package/dist/src/databases/directives.d.ts.map +1 -1
  66. package/dist/src/databases/engines/mssql/auth.d.ts.map +1 -1
  67. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  68. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  69. package/dist/src/databases/engines/mssql/getStructure.d.ts +12 -12
  70. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  71. package/dist/src/databases/engines/mssql/getViews.d.ts.map +1 -1
  72. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  73. package/dist/src/databases/engines/mysql/auth.d.ts.map +1 -1
  74. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  75. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  76. package/dist/src/databases/engines/mysql/getStructure.d.ts +12 -12
  77. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  78. package/dist/src/databases/engines/mysql/getViews.d.ts.map +1 -1
  79. package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -1
  80. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  81. package/dist/src/databases/engines/postgresql/auth.d.ts.map +1 -1
  82. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  83. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  84. package/dist/src/databases/engines/postgresql/getStructure.d.ts +12 -12
  85. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  86. package/dist/src/databases/engines/postgresql/getViews.d.ts.map +1 -1
  87. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  88. package/dist/src/databases/engines/shared/claims.d.ts.map +1 -1
  89. package/dist/src/databases/high-level-operations.d.ts +10 -59
  90. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  91. package/dist/src/databases/metadata/structure.d.ts +12 -12
  92. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  93. package/dist/src/databases/schemaBuilder/generateCreateTable/index.d.ts.map +1 -1
  94. package/dist/src/databases/schemaBuilder/generateCreateView/index.d.ts.map +1 -1
  95. package/dist/src/databases/schemaBuilder/generateInsert/index.d.ts.map +1 -1
  96. package/dist/src/databases/schemaBuilder/generateSchemaCreation/index.d.ts.map +1 -1
  97. package/dist/src/databases/schemaBuilder/mssqlToPostgres/convertView.d.ts.map +1 -1
  98. package/dist/src/databases/schemaBuilder/utils.d.ts +2 -2
  99. package/dist/src/databases/schemaBuilder/utils.d.ts.map +1 -1
  100. package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
  101. package/dist/src/databases/transformers/data-transformers.d.ts +34 -32
  102. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  103. package/dist/src/databases/transformers/genResolverName.d.ts.map +1 -1
  104. package/dist/src/databases/transformers/graphqlName.d.ts.map +1 -1
  105. package/dist/src/index.d.ts +6 -5
  106. package/dist/src/index.d.ts.map +1 -1
  107. package/dist/src/logging/index.d.ts.map +1 -1
  108. package/dist/src/queues/kafka.d.ts.map +1 -1
  109. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  110. package/dist/src/remoteREST/index.d.ts.map +1 -1
  111. package/dist/src/remoteREST/parse.d.ts.map +1 -1
  112. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  113. package/dist/src/remoteREST/transform.d.ts.map +1 -1
  114. package/dist/src/remoteSchemas/index.d.ts.map +1 -1
  115. package/dist/src/remoteSchemas/introspect.d.ts.map +1 -1
  116. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  117. package/dist/src/remoteSchemas/transform.d.ts.map +1 -1
  118. package/dist/src/singletons/ai.d.ts.map +1 -1
  119. package/dist/src/singletons/authentication.d.ts.map +1 -1
  120. package/dist/src/singletons/cache/lruCacheStore.d.ts.map +1 -1
  121. package/dist/src/singletons/cache/redisCacheStore.d.ts.map +1 -1
  122. package/dist/src/singletons/cache/registry.d.ts.map +1 -1
  123. package/dist/src/singletons/cron.d.ts.map +1 -1
  124. package/dist/src/singletons/databases.d.ts.map +1 -1
  125. package/dist/src/singletons/env.d.ts +190 -187
  126. package/dist/src/singletons/env.d.ts.map +1 -1
  127. package/dist/src/singletons/queues.d.ts.map +1 -1
  128. package/dist/src/subscriptions/strategies/index.d.ts.map +1 -1
  129. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  130. package/dist/src/types/db.d.ts.map +1 -1
  131. package/dist/src/types/env.d.ts +641 -630
  132. package/dist/src/types/env.d.ts.map +1 -1
  133. package/dist/src/types/resolver.d.ts.map +1 -1
  134. package/dist/src/types/zod/auth.d.ts +18 -18
  135. package/dist/src/types/zod/configuration.d.ts +298 -293
  136. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  137. package/dist/src/types/zod/db.d.ts +80 -80
  138. package/dist/src/types/zod/db.d.ts.map +1 -1
  139. package/dist/src/types/zod/queue.d.ts +80 -80
  140. package/dist/src/types/zod/queue.d.ts.map +1 -1
  141. package/dist/src/utils/benchmark.d.ts.map +1 -1
  142. package/dist/src/utils/responses.d.ts.map +1 -1
  143. package/dist/src/utils/safeJSONParse.d.ts.map +1 -1
  144. package/dist/src/utils/selection.d.ts.map +1 -1
  145. package/dist/src/utils/sessionVariables.d.ts +5 -1
  146. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  147. package/dist/src/utils/writeSchema.d.ts.map +1 -1
  148. package/dist/tsconfig.tsbuildinfo +1 -1
  149. package/package.json +5 -5
  150. package/playgrounds/console/index.html +18 -15
  151. package/playgrounds/graphiql/index.html +854 -622
  152. package/playgrounds/scalar/index.html +5 -5
  153. package/src/ai/README.md +257 -0
  154. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +7 -1
  155. package/src/analyzeQuery/resolveVariables.ts +130 -16
  156. package/src/config/types/auth.ts +7 -1
  157. package/src/config/types/db.ts +8 -0
  158. package/src/databases/engines/mysql/connection.ts +1 -0
  159. package/src/logging/index.ts +3 -1
  160. package/src/types/db.ts +0 -1
  161. package/src/types/zod/db.ts +1 -7
  162. package/src/types/zod/queue.ts +2 -10
  163. package/src/utils/sessionVariables.ts +7 -1
@@ -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
- if (isArray && entities.queriesMap[fieldName]?.rolePermission?.filter) {
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
- if (obj.startsWith("$")) {
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
- // No variable definitions still need to resolve session variables in fields
385
- const fields = session
386
- ? resolveFieldArguments(operation.fields, new Map(), session)
387
- : operation.fields;
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
- const fields =
408
- flattenResult.resolvedMap.size > 0 || session
409
- ? resolveFieldArguments(operation.fields, flattenResult.resolvedMap, session)
410
- : operation.fields;
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
- variables: resolvedVarDefs.length > 0 ? resolvedVarDefs : operation.variables!,
422
- allVariables,
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
  };
@@ -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.record(z.enum(FILTER_OPERATORS), z.unknown()), FilterConditionZod]),
77
+ z.union([z.partialRecord(z.enum(FILTER_OPERATORS), z.unknown()), FilterConditionZod]),
72
78
  ),
73
79
  );
74
80
 
@@ -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) */
@@ -22,6 +22,7 @@ export const getPool = async (db: Database) => {
22
22
  connectionTimeout: opts?.connectionTimeout,
23
23
  maxLifetime: opts?.maxLifetime,
24
24
  tls: opts?.tls,
25
+ allowPublicKeyRetrieval: opts?.allowPublicKeyRetrieval,
25
26
  prepare: opts?.prepare,
26
27
  bigint: opts?.bigint,
27
28
  });
@@ -3,7 +3,9 @@ import pino from "pino";
3
3
  const DEFAULT_LEVEL =
4
4
  process.env.LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug");
5
5
 
6
- const isDev = process.env.NODE_ENV !== "production";
6
+ // `bun test` sets NODE_ENV=test: pino-pretty runs on a worker thread, and
7
+ // Bun's test runner cannot construct one (thread-stream crashes on import).
8
+ const isDev = process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test";
7
9
 
8
10
  const DEFAULT_OPTIONS: pino.LoggerOptions = {
9
11
  level: DEFAULT_LEVEL,
package/src/types/db.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  TableColumnZod,
16
16
  TableZod,
17
17
  ViewZod,
18
- VirtualColumnZod,
19
18
  } from "./zod/db";
20
19
 
21
20
  // Re-export VirtualColumnType from the config module
@@ -1,12 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
- import {
4
- BunSQLConnectionOptionsZod,
5
- DatabaseConnectionZod,
6
- DatabaseSchemaConfigZod,
7
- MSSQLConnectionOptionsZod,
8
- TableRelationshipZod as TableRelationshipBaseZod,
9
- } from "../../config";
3
+ import { TableRelationshipZod as TableRelationshipBaseZod } from "../../config";
10
4
  import { genResolverName } from "../../databases/transformers/genResolverName";
11
5
  import { sanitizeGraphQLName } from "../../databases/transformers/graphqlName";
12
6
 
@@ -1,15 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
- import {
4
- BaseQueueConfigZod,
5
- KafkaConnectionZod,
6
- PublisherConfigZod,
7
- RabbitMQConnectionZod,
8
- ReconnectConfigZod,
9
- SubscriberConfigZod,
10
- TopicConfigZod,
11
- } from "../../config";
12
- import type { CacheContext, SubscriberHandler } from "../../config";
3
+ import { BaseQueueConfigZod, KafkaConnectionZod, RabbitMQConnectionZod } from "../../config";
4
+ import type { SubscriberHandler } from "../../config";
13
5
 
14
6
  // Re-export base types and schemas from the config module
15
7
  export type {
@@ -37,6 +37,10 @@ const resolveDotPath = (obj: Record<string, unknown>, path: string): unknown =>
37
37
  *
38
38
  * @param filter - Filter object with potential session variables
39
39
  * @param session - JWT payload containing user session data
40
+ * @param onResolved - Called with each substituted claim value; its return value
41
+ * is what lands in the filter. The caller uses this to parameterize the value
42
+ * rather than letting it be inlined into SQL — a claim is attacker-influenced
43
+ * whenever users can change their own profile data.
40
44
  * @returns Filter object with session variables replaced
41
45
  *
42
46
  * @example
@@ -54,6 +58,7 @@ const resolveDotPath = (obj: Record<string, unknown>, path: string): unknown =>
54
58
  export const replaceSessionVariables = (
55
59
  filter: Record<string, unknown>,
56
60
  session: SessionContext | null,
61
+ onResolved?: (value: unknown) => unknown,
57
62
  ): Record<string, unknown> => {
58
63
  if (!filter || !session) {
59
64
  return filter;
@@ -79,7 +84,7 @@ export const replaceSessionVariables = (
79
84
  );
80
85
  }
81
86
 
82
- processedOperators[operator] = sessionValue;
87
+ processedOperators[operator] = onResolved ? onResolved(sessionValue) : sessionValue;
83
88
  }
84
89
  // Recursively handle nested objects
85
90
  else if (
@@ -90,6 +95,7 @@ export const replaceSessionVariables = (
90
95
  processedOperators[operator] = replaceSessionVariables(
91
96
  operatorValue as Record<string, unknown>,
92
97
  session,
98
+ onResolved,
93
99
  );
94
100
  }
95
101
  // Keep other values as-is