@graphoria/server 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +25 -20
  2. package/dist/src/ai/tools/core.d.ts +6 -1
  3. package/dist/src/ai/tools/core.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.test.d.ts +2 -0
  5. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  6. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  7. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  9. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  10. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  11. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  12. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  13. package/dist/src/config/types/ai.d.ts +0 -6
  14. package/dist/src/config/types/ai.d.ts.map +1 -1
  15. package/dist/src/config/types/auth.d.ts +8 -29
  16. package/dist/src/config/types/auth.d.ts.map +1 -1
  17. package/dist/src/config/types/cron.d.ts +0 -94
  18. package/dist/src/config/types/cron.d.ts.map +1 -1
  19. package/dist/src/config/types/db.d.ts +2 -87
  20. package/dist/src/config/types/db.d.ts.map +1 -1
  21. package/dist/src/config/types/operation.d.ts +4 -0
  22. package/dist/src/config/types/operation.d.ts.map +1 -1
  23. package/dist/src/config/types/operation.test.d.ts +2 -0
  24. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  25. package/dist/src/config/types/queue.d.ts +0 -47
  26. package/dist/src/config/types/queue.d.ts.map +1 -1
  27. package/dist/src/config/types/remote-rest.d.ts +0 -9
  28. package/dist/src/config/types/remote-rest.d.ts.map +1 -1
  29. package/dist/src/config/types/remote-schema.d.ts +0 -10
  30. package/dist/src/config/types/remote-schema.d.ts.map +1 -1
  31. package/dist/src/configuration/crossReferences.d.ts +27 -0
  32. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  33. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  34. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  35. package/dist/src/configuration/getSchemas/index.d.ts +70 -4
  36. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  37. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
  38. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  39. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  40. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  41. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  42. package/dist/src/configuration/index.d.ts +41 -2
  43. package/dist/src/configuration/index.d.ts.map +1 -1
  44. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  45. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  46. package/dist/src/console/api.d.ts +7 -5
  47. package/dist/src/console/api.d.ts.map +1 -1
  48. package/dist/src/console/session.d.ts +21 -0
  49. package/dist/src/console/session.d.ts.map +1 -0
  50. package/dist/src/console/session.test.d.ts +2 -0
  51. package/dist/src/console/session.test.d.ts.map +1 -0
  52. package/dist/src/cron/index.d.ts +1 -1
  53. package/dist/src/cron/index.d.ts.map +1 -1
  54. package/dist/src/cron/index.test.d.ts +2 -0
  55. package/dist/src/cron/index.test.d.ts.map +1 -0
  56. package/dist/src/databases/common.d.ts +12 -4
  57. package/dist/src/databases/common.d.ts.map +1 -1
  58. package/dist/src/databases/core/executor.d.ts +6 -2
  59. package/dist/src/databases/core/executor.d.ts.map +1 -1
  60. package/dist/src/databases/core/function-mapping.d.ts +4 -3
  61. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  62. package/dist/src/databases/core/query-builder.d.ts +7 -1
  63. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  64. package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
  65. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  66. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  67. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  68. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  69. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  70. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  71. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  72. package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
  73. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  74. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  75. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  76. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  77. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  78. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  79. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  80. package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
  81. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  82. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  83. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  84. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  85. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  86. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  87. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  88. package/dist/src/databases/high-level-operations.d.ts +2 -0
  89. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  90. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  91. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/singletons/env.d.ts +19 -0
  94. package/dist/src/singletons/env.d.ts.map +1 -1
  95. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  96. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  97. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  98. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  99. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  100. package/dist/src/subscriptions/types.d.ts +4 -1
  101. package/dist/src/subscriptions/types.d.ts.map +1 -1
  102. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  103. package/dist/src/types/env.d.ts +73 -0
  104. package/dist/src/types/env.d.ts.map +1 -1
  105. package/dist/src/types/zod/auth.d.ts +12 -0
  106. package/dist/src/types/zod/auth.d.ts.map +1 -1
  107. package/dist/src/types/zod/configuration.d.ts +28 -0
  108. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  109. package/dist/src/utils/rateLimit.d.ts +65 -0
  110. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  111. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  112. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  113. package/dist/src/utils/responses.d.ts +3 -0
  114. package/dist/src/utils/responses.d.ts.map +1 -1
  115. package/dist/tsconfig.tsbuildinfo +1 -1
  116. package/package.json +13 -13
  117. package/playgrounds/console/index.html +7 -7
  118. package/playgrounds/graphiql/index.html +535 -536
  119. package/playgrounds/scalar/index.html +1 -1
  120. package/src/ai/tools/core.ts +30 -6
  121. package/src/analyzeQuery/costLimit.ts +236 -0
  122. package/src/analyzeQuery/depthLimit.ts +4 -23
  123. package/src/analyzeQuery/fragments.ts +18 -0
  124. package/src/config/types/auth.ts +7 -0
  125. package/src/config/types/db.ts +7 -0
  126. package/src/config/types/operation.ts +4 -0
  127. package/src/configuration/crossReferences.ts +340 -0
  128. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  129. package/src/configuration/gql/buildExecute.ts +1 -1
  130. package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
  131. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
  132. package/src/configuration/index.ts +24 -6
  133. package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
  134. package/src/configuration/rest/index.ts +11 -2
  135. package/src/console/api.ts +51 -10
  136. package/src/console/session.ts +125 -0
  137. package/src/cron/index.ts +6 -2
  138. package/src/databases/common.ts +55 -10
  139. package/src/databases/core/executor.ts +12 -2
  140. package/src/databases/core/function-mapping.ts +5 -0
  141. package/src/databases/core/query-builder.ts +7 -0
  142. package/src/databases/engines/mssql/connection.ts +66 -14
  143. package/src/databases/engines/mssql/format.ts +3 -1
  144. package/src/databases/engines/mssql/query/index.ts +12 -2
  145. package/src/databases/engines/mysql/connection.ts +30 -13
  146. package/src/databases/engines/mysql/format.ts +3 -1
  147. package/src/databases/engines/mysql/query/index.ts +25 -4
  148. package/src/databases/engines/postgresql/connection.ts +76 -15
  149. package/src/databases/engines/postgresql/format.ts +3 -1
  150. package/src/databases/engines/postgresql/query/index.ts +12 -3
  151. package/src/databases/high-level-operations.ts +13 -2
  152. package/src/index.ts +82 -13
  153. package/src/singletons/queryTimeout.ts +20 -0
  154. package/src/subscriptions/strategies/database.ts +19 -3
  155. package/src/subscriptions/types.ts +4 -1
  156. package/src/subscriptions/utils/polling.ts +6 -3
  157. package/src/types/env.ts +21 -1
  158. package/src/utils/rateLimit.ts +209 -0
  159. package/src/utils/responses.ts +10 -0
  160. package/src/ai/README.md +0 -257
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun";
2
2
  import type { AnalysisResult, SelectionAnalysis, VariableDefinition } from "../analyzeQuery/types";
3
3
  import type { GetSchemaReturn } from "../configuration/getSchemas";
4
4
  import type { createQueryEventEmitter } from "../utils/event-emitter";
5
+ import type { SessionContext } from "../utils/sessionVariables";
5
6
 
6
7
  /**
7
8
  * Type for the query event emitter instance
@@ -24,7 +25,7 @@ export interface SubscriptionContext {
24
25
  ws: ServerWebSocket<unknown>;
25
26
  /** Unique subscription ID from the client */
26
27
  subscriptionId: string;
27
- /** Analyzed GraphQL query */
28
+ /** Analyzed GraphQL query, with variables and `$session.*` already resolved */
28
29
  analysis: AnalysisResult;
29
30
  /** The first field being subscribed to */
30
31
  field: SelectionAnalysis;
@@ -34,6 +35,8 @@ export interface SubscriptionContext {
34
35
  variables: Record<string, unknown>;
35
36
  /** Schema and entity information for the current role */
36
37
  schemaEntity: GetSchemaReturn;
38
+ /** The caller this subscription belongs to. Load-bearing: it keys the group. */
39
+ session: SessionContext;
37
40
  /** Shared event emitter for broadcasting updates */
38
41
  eventEmitter: QueryEventEmitter;
39
42
  }
@@ -5,6 +5,7 @@ import type { DatabasePoller, QueryEventEmitter } from "../types";
5
5
 
6
6
  import { executeQuery, executeQueryJSON, generateSQL } from "../../databases";
7
7
  import { logger } from "../../logging";
8
+ import { env } from "../../singletons/env";
8
9
 
9
10
  export interface DatabasePollerConfig {
10
11
  analysis: AnalysisResult;
@@ -60,9 +61,11 @@ export const createDatabasePoller = async (
60
61
  const firstFieldName = getFirstFieldName(analysis);
61
62
  const db = schemaEntity.queriesMap[firstFieldName!]!.db!;
62
63
 
63
- // Generate SQL queries (data query and hash query)
64
- const [[, queryData]] = generateSQL(schemaEntity, analysis, variables);
65
- const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true);
64
+ // Generate SQL queries (data query and hash query). A subscription query is
65
+ // caller-authored, so it carries the same page bounds an HTTP query does.
66
+ const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
67
+ const [[, queryData]] = generateSQL(schemaEntity, analysis, variables, false, pageLimits);
68
+ const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true, pageLimits);
66
69
 
67
70
  // Get initial hash and send initial data
68
71
  let previousHash = await getResultHash(queryHash, db, variableDefinitions, variables);
package/src/types/env.ts CHANGED
@@ -30,12 +30,21 @@ export const EnvZod = z
30
30
  // Boolean flags use stringbool, not coerce.boolean: Boolean("false") === true would flip them on
31
31
  CONSOLE_ENABLED: z.stringbool().default(false),
32
32
  CONSOLE_ENDPOINT: z.string().default("/_console"),
33
+ CONSOLE_SESSION_EXPIRES_IN: z.string().default("1h"),
33
34
  PREFIX: z.string().default(""),
34
35
  CORS_ENABLED: z.stringbool().default(true),
35
36
  PRINT_SCHEMAS: z.stringbool().default(false),
36
37
  SCHEMAS_OUTPUT_DIR: z.string().default("./.graphoria/schemas"),
37
38
  QUERY_ON_RESPONSE: z.stringbool().default(false),
38
- MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(0),
39
+ MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(8),
40
+ DEFAULT_PAGE_SIZE: z.coerce.number().int().min(0).default(100),
41
+ MAX_PAGE_SIZE: z.coerce.number().int().min(0).default(1000),
42
+ QUERY_TIMEOUT_MS: z.coerce.number().int().min(0).default(10000),
43
+ MAX_QUERY_COST: z.coerce.number().int().min(0).default(0),
44
+ RATE_LIMIT_MAX: z.coerce.number().int().min(0).default(0),
45
+ RATE_LIMIT_ANONYMOUS_MAX: z.coerce.number().int().min(0).default(0),
46
+ RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
47
+ RATE_LIMIT_TRUST_PROXY: z.stringbool().default(false),
39
48
  AI_ENABLED: z.stringbool().optional(),
40
49
  AI_GRAPHQL_ENABLED: z.stringbool().default(true),
41
50
  AI_REST_ENABLED: z.stringbool().default(true),
@@ -66,6 +75,10 @@ export const EnvZod = z
66
75
  authStrategy: env.AUTH_STRATEGY,
67
76
  queryOnResponse: env.QUERY_ON_RESPONSE,
68
77
  maxQueryDepth: env.MAX_QUERY_DEPTH,
78
+ defaultPageSize: env.DEFAULT_PAGE_SIZE,
79
+ maxPageSize: env.MAX_PAGE_SIZE,
80
+ queryTimeoutMs: env.QUERY_TIMEOUT_MS,
81
+ maxQueryCost: env.MAX_QUERY_COST,
69
82
  admin: {
70
83
  secret: env.ADMIN_SECRET,
71
84
  header: env.ADMIN_SECRET_HEADER,
@@ -80,6 +93,12 @@ export const EnvZod = z
80
93
  secretKey: env.PASETO_SECRET_KEY,
81
94
  publicKey: env.PASETO_PUBLIC_KEY,
82
95
  },
96
+ rateLimit: {
97
+ max: env.RATE_LIMIT_MAX,
98
+ anonymousMax: env.RATE_LIMIT_ANONYMOUS_MAX,
99
+ windowMs: env.RATE_LIMIT_WINDOW_MS,
100
+ trustProxy: env.RATE_LIMIT_TRUST_PROXY,
101
+ },
83
102
  cache: {
84
103
  store: env.CACHE_STORE,
85
104
  redisUrl: env.REDIS_URL,
@@ -94,6 +113,7 @@ export const EnvZod = z
94
113
  console: {
95
114
  enabled: env.CONSOLE_ENABLED,
96
115
  endpoint: env.CONSOLE_ENDPOINT,
116
+ sessionExpiresIn: env.CONSOLE_SESSION_EXPIRES_IN,
97
117
  },
98
118
  ai: {
99
119
  enabled: env.AI_ENABLED,
@@ -0,0 +1,209 @@
1
+ import { LRUCache } from "lru-cache";
2
+
3
+ import { logger } from "../logging";
4
+
5
+ export type ConsumeResult = { allowed: boolean; retryAfterMs: number };
6
+
7
+ /**
8
+ * One method, everything per call: a role with its own limit shares the store
9
+ * with every other role rather than needing an instance of its own.
10
+ */
11
+ export interface RateLimitStore {
12
+ consume(
13
+ key: string,
14
+ capacity: number,
15
+ refillPerMs: number,
16
+ ttlMs: number,
17
+ ): Promise<ConsumeResult>;
18
+ }
19
+
20
+ type Bucket = { tokens: number; ts: number };
21
+
22
+ export type MemoryRateLimitStoreOptions = {
23
+ now?: () => number;
24
+ maxKeys?: number;
25
+ };
26
+
27
+ const DEFAULT_MAX_KEYS = 10_000;
28
+
29
+ export const createMemoryRateLimitStore = ({
30
+ now = Date.now,
31
+ maxKeys = DEFAULT_MAX_KEYS,
32
+ }: MemoryRateLimitStoreOptions = {}): RateLimitStore => {
33
+ // The bound is load-bearing: an unbounded map keyed by client address is
34
+ // itself a memory-exhaustion vector. Evicting a bucket only ever gives its
35
+ // owner a full one, which is what an idle caller would have had anyway.
36
+ const buckets = new LRUCache<string, Bucket>({ max: maxKeys });
37
+
38
+ return {
39
+ consume: async (key, capacity, refillPerMs, ttlMs) => {
40
+ const nowMs = now();
41
+ const bucket = buckets.get(key);
42
+ const tokens = bucket
43
+ ? Math.min(capacity, bucket.tokens + (nowMs - bucket.ts) * refillPerMs)
44
+ : capacity;
45
+
46
+ if (tokens < 1) {
47
+ buckets.set(key, { tokens, ts: nowMs }, { ttl: ttlMs });
48
+ return { allowed: false, retryAfterMs: Math.ceil((1 - tokens) / refillPerMs) };
49
+ }
50
+
51
+ buckets.set(key, { tokens: tokens - 1, ts: nowMs }, { ttl: ttlMs });
52
+ return { allowed: true, retryAfterMs: 0 };
53
+ },
54
+ };
55
+ };
56
+
57
+ export type RateLimitRedisClient = {
58
+ send(command: string, args: string[]): Promise<unknown>;
59
+ };
60
+
61
+ type RateLimitLogger = { warn(obj: object, msg: string): void };
62
+
63
+ export type RedisRateLimitStoreOptions = {
64
+ now?: () => number;
65
+ log?: RateLimitLogger;
66
+ };
67
+
68
+ // KEYS[1] = bucket key, ARGV = capacity, refillPerMs, nowMs, ttlMs. Refill,
69
+ // read and write have to happen inside Redis: doing them from here would race
70
+ // between workers, which is the whole reason for reaching for Redis at all.
71
+ const CONSUME_SCRIPT = `
72
+ local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
73
+ local tokens = tonumber(b[1]) or tonumber(ARGV[1])
74
+ local ts = tonumber(b[2]) or tonumber(ARGV[3])
75
+ tokens = math.min(tonumber(ARGV[1]), tokens + (tonumber(ARGV[3]) - ts) * tonumber(ARGV[2]))
76
+ local allowed = 0
77
+ if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
78
+ redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
79
+ redis.call('PEXPIRE', KEYS[1], ARGV[4])
80
+ return { allowed, tostring(tokens) }
81
+ `;
82
+
83
+ export const createRedisRateLimitStore = (
84
+ client: RateLimitRedisClient,
85
+ { now = Date.now, log = logger("rate-limit") }: RedisRateLimitStoreOptions = {},
86
+ ): RateLimitStore => {
87
+ let lastWarnMs: number | undefined;
88
+
89
+ return {
90
+ consume: async (key, capacity, refillPerMs, ttlMs) => {
91
+ const nowMs = now();
92
+
93
+ try {
94
+ const reply = (await client.send("EVAL", [
95
+ CONSUME_SCRIPT,
96
+ "1",
97
+ key,
98
+ String(capacity),
99
+ String(refillPerMs),
100
+ String(nowMs),
101
+ String(ttlMs),
102
+ ])) as [number | string, string];
103
+
104
+ const allowed = Number(reply[0]) === 1;
105
+ const tokens = Number(reply[1]);
106
+
107
+ return allowed
108
+ ? { allowed: true, retryAfterMs: 0 }
109
+ : { allowed: false, retryAfterMs: Math.ceil((1 - tokens) / refillPerMs) };
110
+ } catch (error) {
111
+ // A dead cache must not take the API down, and a dead cache must not
112
+ // produce a log line per request either — the flood would outlast the
113
+ // outage.
114
+ if (lastWarnMs === undefined || nowMs - lastWarnMs >= ttlMs) {
115
+ lastWarnMs = nowMs;
116
+ log.warn({ err: error }, "rate limit store unavailable, allowing the request");
117
+ }
118
+ return { allowed: true, retryAfterMs: 0 };
119
+ }
120
+ },
121
+ };
122
+ };
123
+
124
+ export type RateLimitSettings = {
125
+ max: number;
126
+ anonymousMax: number;
127
+ windowMs: number;
128
+ trustProxy: boolean;
129
+ };
130
+
131
+ type RoleRateLimit = { rateLimit?: { max: number; windowMs?: number } | undefined };
132
+
133
+ export type RateLimiter = {
134
+ check(
135
+ session: { role?: string | undefined; sub?: string | undefined } | null,
136
+ address: string | undefined,
137
+ ): Promise<ConsumeResult>;
138
+ };
139
+
140
+ export type CreateRateLimiterOptions = {
141
+ settings: RateLimitSettings;
142
+ anonymousRole: string;
143
+ permissions?: Record<string, RoleRateLimit | undefined>;
144
+ /** A factory is only called once a limit is configured, so a disabled
145
+ * limiter never constructs a store — or a Redis client. */
146
+ store?: RateLimitStore | (() => RateLimitStore);
147
+ };
148
+
149
+ // Attacker-controlled once RATE_LIMIT_TRUST_PROXY is on, and it ends up in a
150
+ // store key, so it is capped before it gets there.
151
+ const MAX_ADDRESS_LENGTH = 64;
152
+
153
+ export const resolveClientAddress = (
154
+ req: Request,
155
+ server: { requestIP(req: Request): { address: string } | null } | undefined,
156
+ trustProxy: boolean,
157
+ ): string | undefined => {
158
+ if (trustProxy) {
159
+ const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
160
+ if (forwarded) return forwarded.slice(0, MAX_ADDRESS_LENGTH);
161
+ }
162
+
163
+ return server?.requestIP(req)?.address;
164
+ };
165
+
166
+ /**
167
+ * `undefined` when nothing is configured, so the caller can skip the wrapper
168
+ * entirely and the shipped default costs nothing per request.
169
+ */
170
+ export const createRateLimiter = ({
171
+ settings,
172
+ anonymousRole,
173
+ permissions = {},
174
+ store,
175
+ }: CreateRateLimiterOptions): RateLimiter | undefined => {
176
+ const configured =
177
+ settings.max > 0 ||
178
+ settings.anonymousMax > 0 ||
179
+ Object.values(permissions).some((permission) => (permission?.rateLimit?.max ?? 0) > 0);
180
+
181
+ if (!configured) return undefined;
182
+
183
+ const resolvedStore =
184
+ typeof store === "function" ? store() : (store ?? createMemoryRateLimitStore());
185
+
186
+ const limitFor = (role: string) => {
187
+ const roleLimit = permissions[role]?.rateLimit;
188
+
189
+ return {
190
+ max: roleLimit?.max ?? (role === anonymousRole ? settings.anonymousMax : settings.max),
191
+ windowMs: roleLimit?.windowMs ?? settings.windowMs,
192
+ };
193
+ };
194
+
195
+ return {
196
+ check: async (session, address) => {
197
+ const role = session?.role ?? anonymousRole;
198
+ const { max, windowMs } = limitFor(role);
199
+
200
+ if (max === 0) return { allowed: true, retryAfterMs: 0 };
201
+
202
+ const sub = session?.sub;
203
+ const key =
204
+ sub && sub !== "anonymous" ? `rl:${role}:${sub}` : `rl:${role}:ip:${address ?? "unknown"}`;
205
+
206
+ return resolvedStore.consume(key, max, max / windowMs, windowMs);
207
+ },
208
+ };
209
+ };
@@ -35,6 +35,16 @@ export class S404 extends ClientResponse {
35
35
  }
36
36
  }
37
37
 
38
+ export class S429 extends ClientResponse {
39
+ constructor(retryAfterMs: number) {
40
+ super({ errors: [{ message: "Rate limit exceeded" }] }, { status: 429 });
41
+
42
+ // Seconds, and never 0: a caller told to retry immediately retries
43
+ // immediately, which is the traffic the limit is there to stop.
44
+ this.headers.set("Retry-After", String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
45
+ }
46
+ }
47
+
38
48
  export class S500 extends ClientResponse {
39
49
  constructor(body?: object | null, init?: ResponseInit) {
40
50
  super(body, { ...init, status: 500 });
package/src/ai/README.md DELETED
@@ -1,257 +0,0 @@
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