@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.
- package/README.md +25 -20
- package/dist/src/ai/tools/core.d.ts +6 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/core.test.d.ts +2 -0
- package/dist/src/ai/tools/core.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
- package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/fragments.d.ts +6 -0
- package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
- package/dist/src/config/types/ai.d.ts +0 -6
- package/dist/src/config/types/ai.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +8 -29
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/cron.d.ts +0 -94
- package/dist/src/config/types/cron.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -87
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +4 -0
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/operation.test.d.ts +2 -0
- package/dist/src/config/types/operation.test.d.ts.map +1 -0
- package/dist/src/config/types/queue.d.ts +0 -47
- package/dist/src/config/types/queue.d.ts.map +1 -1
- package/dist/src/config/types/remote-rest.d.ts +0 -9
- package/dist/src/config/types/remote-rest.d.ts.map +1 -1
- package/dist/src/config/types/remote-schema.d.ts +0 -10
- package/dist/src/config/types/remote-schema.d.ts.map +1 -1
- package/dist/src/configuration/crossReferences.d.ts +27 -0
- package/dist/src/configuration/crossReferences.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.test.d.ts +2 -0
- package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +70 -4
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
- 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 +41 -2
- package/dist/src/configuration/index.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/console/api.d.ts +7 -5
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +21 -0
- package/dist/src/console/session.d.ts.map +1 -0
- package/dist/src/console/session.test.d.ts +2 -0
- package/dist/src/console/session.test.d.ts.map +1 -0
- package/dist/src/cron/index.d.ts +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/cron/index.test.d.ts +2 -0
- package/dist/src/cron/index.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +12 -4
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +6 -2
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +4 -3
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/format.d.ts +2 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/format.d.ts +2 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +2 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +19 -0
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queryTimeout.d.ts +3 -0
- package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
- package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
- package/dist/src/subscriptions/types.d.ts +4 -1
- package/dist/src/subscriptions/types.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +73 -0
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +12 -0
- package/dist/src/types/zod/auth.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +28 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts +65 -0
- package/dist/src/utils/rateLimit.d.ts.map +1 -0
- package/dist/src/utils/rateLimit.test.d.ts +2 -0
- package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +3 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +535 -536
- package/playgrounds/scalar/index.html +1 -1
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -0
- package/src/config/types/auth.ts +7 -0
- package/src/config/types/db.ts +7 -0
- package/src/config/types/operation.ts +4 -0
- package/src/configuration/crossReferences.ts +340 -0
- package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +51 -10
- package/src/console/session.ts +125 -0
- package/src/cron/index.ts +6 -2
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +12 -2
- package/src/databases/core/function-mapping.ts +5 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -14
- package/src/databases/engines/mssql/format.ts +3 -1
- package/src/databases/engines/mssql/query/index.ts +12 -2
- package/src/databases/engines/mysql/connection.ts +30 -13
- package/src/databases/engines/mysql/format.ts +3 -1
- package/src/databases/engines/mysql/query/index.ts +25 -4
- package/src/databases/engines/postgresql/connection.ts +76 -15
- package/src/databases/engines/postgresql/format.ts +3 -1
- package/src/databases/engines/postgresql/query/index.ts +12 -3
- package/src/databases/high-level-operations.ts +13 -2
- package/src/index.ts +82 -13
- package/src/singletons/queryTimeout.ts +20 -0
- package/src/subscriptions/strategies/database.ts +19 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/subscriptions/utils/polling.ts +6 -3
- package/src/types/env.ts +21 -1
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +10 -0
- 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
|
-
|
|
65
|
-
const
|
|
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(
|
|
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
|
+
};
|
package/src/utils/responses.ts
CHANGED
|
@@ -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
|