@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
|
@@ -5,27 +5,44 @@ import type { VariableDefinition } from "../../../analyzeQuery/types";
|
|
|
5
5
|
import type { Database } from "../../../types/configuration";
|
|
6
6
|
import type { ProcedureResolver } from "../../../types/db";
|
|
7
7
|
|
|
8
|
+
import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
|
|
8
9
|
import { databasesConnections } from "../../../singletons/databases";
|
|
9
10
|
import { toMySQLPlaceholders } from "./placeholders";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Every pool bound comes from the schema, which is the only place they are
|
|
13
|
+
// declared and documented. Repeating them here as `?? n` fallbacks let the
|
|
14
|
+
// documented default and the applied one drift apart, differently per engine.
|
|
15
|
+
//
|
|
16
|
+
// Defaults are overlaid rather than re-parsed: `connectionOptions` is an
|
|
17
|
+
// undiscriminated union of the two engine shapes, so an object can arrive
|
|
18
|
+
// already validated against the other one, and a strict re-parse would reject
|
|
19
|
+
// it at boot.
|
|
20
|
+
export const poolOptions = (db: Database) => {
|
|
21
|
+
const opts = {
|
|
22
|
+
...BunSQLConnectionOptionsZod.parse({}),
|
|
23
|
+
...(db.connectionOptions as BunSQLConnectionOptions | undefined),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
14
27
|
host: db.connection.host,
|
|
15
28
|
port: db.connection.port,
|
|
16
29
|
username: db.connection.user,
|
|
17
30
|
password: db.connection.password,
|
|
18
31
|
database: db.connection.database,
|
|
19
|
-
adapter: "mysql",
|
|
20
|
-
max: opts
|
|
21
|
-
idleTimeout: opts
|
|
22
|
-
connectionTimeout: opts
|
|
23
|
-
maxLifetime: opts
|
|
24
|
-
tls: opts
|
|
25
|
-
allowPublicKeyRetrieval: opts
|
|
26
|
-
prepare: opts
|
|
27
|
-
bigint: opts
|
|
28
|
-
}
|
|
32
|
+
adapter: "mysql" as const,
|
|
33
|
+
max: opts.max,
|
|
34
|
+
idleTimeout: opts.idleTimeout,
|
|
35
|
+
connectionTimeout: opts.connectionTimeout,
|
|
36
|
+
maxLifetime: opts.maxLifetime,
|
|
37
|
+
tls: opts.tls,
|
|
38
|
+
allowPublicKeyRetrieval: opts.allowPublicKeyRetrieval,
|
|
39
|
+
prepare: opts.prepare,
|
|
40
|
+
bigint: opts.bigint,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const getPool = async (db: Database) => {
|
|
45
|
+
const pool = new SQL(poolOptions(db));
|
|
29
46
|
|
|
30
47
|
await pool.connect(); // Connect to the database
|
|
31
48
|
|
|
@@ -2,6 +2,7 @@ import { format as sqlFormatter } from "sql-formatter";
|
|
|
2
2
|
|
|
3
3
|
import type { AnalysisResult } from "../../../analyzeQuery/types";
|
|
4
4
|
import type { MergedEntities } from "../../../configuration/getSchemas/mergeEntities";
|
|
5
|
+
import type { PageLimits } from "../../common";
|
|
5
6
|
|
|
6
7
|
import { generateSQL } from "../../core/query-builder";
|
|
7
8
|
|
|
@@ -16,8 +17,9 @@ export const genSql = (
|
|
|
16
17
|
query: AnalysisResult,
|
|
17
18
|
variables: Record<string, unknown> = {},
|
|
18
19
|
hash = false,
|
|
20
|
+
pageLimits: PageLimits | null = null,
|
|
19
21
|
) => {
|
|
20
|
-
const [[, sql]] = generateSQL(entitites, query, variables, hash);
|
|
22
|
+
const [[, sql]] = generateSQL(entitites, query, variables, hash, pageLimits);
|
|
21
23
|
|
|
22
24
|
return format(sql);
|
|
23
25
|
};
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
VariableDefinition,
|
|
5
5
|
} from "../../../../analyzeQuery/types";
|
|
6
6
|
import type { MergedEntities } from "../../../../configuration/getSchemas/mergeEntities";
|
|
7
|
-
import type { GroupByInfo } from "../../../common";
|
|
7
|
+
import type { GroupByInfo, PageLimits } from "../../../common";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
buildOrderByClauseMySQL,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../../../common";
|
|
22
22
|
|
|
23
23
|
import { applyDirectives } from "../../../directives";
|
|
24
|
+
import { getQueryTimeoutMs } from "../../../../singletons/queryTimeout";
|
|
24
25
|
|
|
25
26
|
// Generate CTE for aggregations
|
|
26
27
|
const buildAggregationCTE = (
|
|
@@ -157,9 +158,21 @@ export const generateSQL = (
|
|
|
157
158
|
operation: OperationAnalysis,
|
|
158
159
|
variables: Record<string, unknown> = {},
|
|
159
160
|
forHashMethod: boolean = false,
|
|
161
|
+
pageLimits: PageLimits | null = null,
|
|
162
|
+
timeoutMs?: number,
|
|
160
163
|
): string => {
|
|
164
|
+
// Bun's MySQL adapter ignores the `connection` bag and exposes no
|
|
165
|
+
// per-connection init hook, so there is no pool-level route for
|
|
166
|
+
// max_execution_time: the bound has to ride in the SQL text. MySQL honours the
|
|
167
|
+
// hint only on the outermost SELECT — one placed inside a CTE is silently
|
|
168
|
+
// discarded — which is why it is emitted here, at each return, rather than
|
|
169
|
+
// spliced into the finished string by the executor.
|
|
170
|
+
const effectiveTimeoutMs = timeoutMs ?? getQueryTimeoutMs();
|
|
171
|
+
const timeoutHint =
|
|
172
|
+
effectiveTimeoutMs > 0 ? ` /*+ MAX_EXECUTION_TIME(${effectiveTimeoutMs}) */` : "";
|
|
173
|
+
|
|
161
174
|
if (forHashMethod) {
|
|
162
|
-
return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {})})) AS ResultHash`;
|
|
175
|
+
return `SELECT${timeoutHint} MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {}, pageLimits)})) AS ResultHash`;
|
|
163
176
|
}
|
|
164
177
|
|
|
165
178
|
const variablesWithDefault = {
|
|
@@ -220,6 +233,7 @@ export const generateSQL = (
|
|
|
220
233
|
null,
|
|
221
234
|
index + 1,
|
|
222
235
|
{},
|
|
236
|
+
pageLimits,
|
|
223
237
|
);
|
|
224
238
|
|
|
225
239
|
fieldQueries.push(`'${field.alias || field.name}', ${fieldSQL}`);
|
|
@@ -228,7 +242,7 @@ export const generateSQL = (
|
|
|
228
242
|
const cteClause = ctes.length > 0 ? `WITH\n${ctes.join(",\n")}\n` : "";
|
|
229
243
|
|
|
230
244
|
return `
|
|
231
|
-
${cteClause}SELECT JSON_OBJECT(
|
|
245
|
+
${cteClause}SELECT${timeoutHint} JSON_OBJECT(
|
|
232
246
|
${fieldQueries.join(",\n")}
|
|
233
247
|
) as json_result`;
|
|
234
248
|
};
|
|
@@ -242,6 +256,7 @@ export const buildSQLForField = (
|
|
|
242
256
|
parentTableAlias: string | null,
|
|
243
257
|
level: number,
|
|
244
258
|
aliasMap: { [alias: string]: string },
|
|
259
|
+
pageLimits: PageLimits | null,
|
|
245
260
|
): string => {
|
|
246
261
|
const tableAlias = generateTableAlias(level);
|
|
247
262
|
|
|
@@ -306,6 +321,7 @@ export const buildSQLForField = (
|
|
|
306
321
|
tableAlias,
|
|
307
322
|
level,
|
|
308
323
|
aliasMap,
|
|
324
|
+
pageLimits,
|
|
309
325
|
),
|
|
310
326
|
([name, selector]) => `'${name}', ${selector}`,
|
|
311
327
|
);
|
|
@@ -313,7 +329,12 @@ export const buildSQLForField = (
|
|
|
313
329
|
const fromClause = `FROM ${dottedQuotedName} ${tableAlias}`;
|
|
314
330
|
|
|
315
331
|
const orderByClause = buildOrderByClauseMySQL(entities, field, tableAlias);
|
|
316
|
-
const paginationClause = buildPaginationClauseMySQL(
|
|
332
|
+
const paginationClause = buildPaginationClauseMySQL(
|
|
333
|
+
field,
|
|
334
|
+
variablesDefinition,
|
|
335
|
+
variables,
|
|
336
|
+
pageLimits,
|
|
337
|
+
);
|
|
317
338
|
|
|
318
339
|
const isArraySelection = !!field.isArray && !withoutArrayWrapper;
|
|
319
340
|
|
|
@@ -5,24 +5,51 @@ import type { VariableDefinition } from "../../../analyzeQuery/types";
|
|
|
5
5
|
import type { Database } from "../../../types/configuration";
|
|
6
6
|
import type { ProcedureResolver } from "../../../types/db";
|
|
7
7
|
|
|
8
|
+
import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
|
|
8
9
|
import { databasesConnections } from "../../../singletons/databases";
|
|
10
|
+
import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
|
|
11
|
+
|
|
12
|
+
// Every pool bound comes from the schema, which is the only place they are
|
|
13
|
+
// declared and documented. Repeating them here as `?? n` fallbacks let the
|
|
14
|
+
// documented default and the applied one drift apart, differently per engine.
|
|
15
|
+
//
|
|
16
|
+
// Defaults are overlaid rather than re-parsed: `connectionOptions` is an
|
|
17
|
+
// undiscriminated union of the two engine shapes, so an object can arrive
|
|
18
|
+
// already validated against the other one, and a strict re-parse would reject
|
|
19
|
+
// it at boot.
|
|
20
|
+
export const poolOptions = (db: Database, timeoutMs: number = getQueryTimeoutMs()) => {
|
|
21
|
+
const opts = {
|
|
22
|
+
...BunSQLConnectionOptionsZod.parse({}),
|
|
23
|
+
...(db.connectionOptions as BunSQLConnectionOptions | undefined),
|
|
24
|
+
};
|
|
9
25
|
|
|
10
|
-
|
|
11
|
-
const opts = db.connectionOptions as BunSQLConnectionOptions | undefined;
|
|
12
|
-
const pool = new SQL({
|
|
26
|
+
return {
|
|
13
27
|
host: db.connection.host,
|
|
14
28
|
port: db.connection.port,
|
|
15
29
|
user: db.connection.user,
|
|
16
30
|
password: db.connection.password,
|
|
17
31
|
database: db.connection.database,
|
|
18
|
-
max: opts
|
|
19
|
-
idleTimeout: opts
|
|
20
|
-
connectionTimeout: opts
|
|
21
|
-
maxLifetime: opts
|
|
22
|
-
tls: opts
|
|
23
|
-
prepare: opts
|
|
24
|
-
bigint: opts
|
|
25
|
-
|
|
32
|
+
max: opts.max,
|
|
33
|
+
idleTimeout: opts.idleTimeout,
|
|
34
|
+
connectionTimeout: opts.connectionTimeout,
|
|
35
|
+
maxLifetime: opts.maxLifetime,
|
|
36
|
+
tls: opts.tls,
|
|
37
|
+
prepare: opts.prepare,
|
|
38
|
+
bigint: opts.bigint,
|
|
39
|
+
// Applied once per pooled connection rather than per query, so it bounds
|
|
40
|
+
// everything that runs on the connection — auth and introspection included
|
|
41
|
+
// — at no per-query cost. Omitted at 0 rather than sent as "0", which
|
|
42
|
+
// Postgres reads as unlimited but still costs a round trip to set.
|
|
43
|
+
...(timeoutMs > 0 ? { connection: { statement_timeout: String(timeoutMs) } } : {}),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The bound the pool already applies, so the executor can tell an override from the default. */
|
|
48
|
+
export const poolStatementTimeoutMs = (db: Database) =>
|
|
49
|
+
Number(poolOptions(db).connection?.statement_timeout ?? 0);
|
|
50
|
+
|
|
51
|
+
export const getPool = async (db: Database) => {
|
|
52
|
+
const pool = new SQL(poolOptions(db));
|
|
26
53
|
|
|
27
54
|
await pool.connect(); // Connect to the database
|
|
28
55
|
|
|
@@ -31,6 +58,35 @@ export const getPool = async (db: Database) => {
|
|
|
31
58
|
|
|
32
59
|
export const getPoolSingleton = async (db: Database) => databasesConnections[db.name] as SQL;
|
|
33
60
|
|
|
61
|
+
// A timeout other than the pool's own has to be set on a connection, which
|
|
62
|
+
// means pinning one for the duration. Only an override pays for that.
|
|
63
|
+
const executeReserved = async <T>(
|
|
64
|
+
pool: SQL,
|
|
65
|
+
timeoutMs: number,
|
|
66
|
+
query: string,
|
|
67
|
+
params: unknown[],
|
|
68
|
+
) => {
|
|
69
|
+
// SET takes no bind parameters, so the value is interpolated. It is an
|
|
70
|
+
// integer from the config schema; re-checked here so a widened type upstream
|
|
71
|
+
// cannot turn this line into an injection point.
|
|
72
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0) {
|
|
73
|
+
throw new Error(`Invalid statement timeout: ${timeoutMs}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const reserved = await pool.reserve();
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await reserved.unsafe(`SET statement_timeout = ${timeoutMs}`);
|
|
80
|
+
|
|
81
|
+
return await reserved.unsafe<T>(query, params);
|
|
82
|
+
} finally {
|
|
83
|
+
// The connection goes back to the pool carrying the pool's default again,
|
|
84
|
+
// not this query's override.
|
|
85
|
+
await reserved.unsafe("RESET statement_timeout");
|
|
86
|
+
await reserved.release();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
34
90
|
export const executeQueryFactory =
|
|
35
91
|
(singleQuery = false) =>
|
|
36
92
|
async <T>(
|
|
@@ -38,13 +94,16 @@ export const executeQueryFactory =
|
|
|
38
94
|
db: Database,
|
|
39
95
|
variablesDefinition: VariableDefinition[],
|
|
40
96
|
values: Record<string, unknown> = {},
|
|
97
|
+
timeoutMs?: number,
|
|
41
98
|
) => {
|
|
42
99
|
const pool = singleQuery ? await getPool(db) : await getPoolSingleton(db);
|
|
100
|
+
const params = variablesDefinition.map((v) => values[v.name]);
|
|
43
101
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
const override = timeoutMs !== undefined && timeoutMs !== poolStatementTimeoutMs(db);
|
|
103
|
+
|
|
104
|
+
const result = override
|
|
105
|
+
? await executeReserved<T>(pool, timeoutMs, query, params)
|
|
106
|
+
: await pool.unsafe<T>(query, params);
|
|
48
107
|
|
|
49
108
|
if (singleQuery) {
|
|
50
109
|
await pool.close();
|
|
@@ -63,12 +122,14 @@ export const executeQueryJSONFactory =
|
|
|
63
122
|
db: Database,
|
|
64
123
|
variablesDefinition: VariableDefinition[] = [],
|
|
65
124
|
values: Record<string, unknown> = {},
|
|
125
|
+
timeoutMs?: number,
|
|
66
126
|
) => {
|
|
67
127
|
const result = await (singleQuery ? executeQuerySingle : executeQuery)<[{ json_result: T }]>(
|
|
68
128
|
query,
|
|
69
129
|
db,
|
|
70
130
|
variablesDefinition,
|
|
71
131
|
values,
|
|
132
|
+
timeoutMs,
|
|
72
133
|
);
|
|
73
134
|
|
|
74
135
|
return result[0].json_result as T;
|
|
@@ -2,6 +2,7 @@ import { format as sqlFormatter } from "sql-formatter";
|
|
|
2
2
|
|
|
3
3
|
import type { AnalysisResult } from "../../../analyzeQuery/types";
|
|
4
4
|
import type { MergedEntities } from "../../../configuration/getSchemas/mergeEntities";
|
|
5
|
+
import type { PageLimits } from "../../common";
|
|
5
6
|
|
|
6
7
|
import { generateSQL } from "../../core/query-builder";
|
|
7
8
|
|
|
@@ -16,8 +17,9 @@ export const genSql = (
|
|
|
16
17
|
query: AnalysisResult,
|
|
17
18
|
variables: Record<string, unknown> = {},
|
|
18
19
|
hash = false,
|
|
20
|
+
pageLimits: PageLimits | null = null,
|
|
19
21
|
) => {
|
|
20
|
-
const [[, sql]] = generateSQL(entitites, query, variables, hash);
|
|
22
|
+
const [[, sql]] = generateSQL(entitites, query, variables, hash, pageLimits);
|
|
21
23
|
|
|
22
24
|
return format(sql);
|
|
23
25
|
};
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
VariableDefinition,
|
|
5
5
|
} from "../../../../analyzeQuery/types";
|
|
6
6
|
import type { MergedEntities } from "../../../../configuration/getSchemas/mergeEntities";
|
|
7
|
-
import type { GroupByInfo } from "../../../common";
|
|
7
|
+
import type { GroupByInfo, PageLimits } from "../../../common";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
buildOrderByClausePG,
|
|
@@ -154,9 +154,10 @@ export const generateSQL = (
|
|
|
154
154
|
operation: OperationAnalysis,
|
|
155
155
|
variables: Record<string, unknown> = {},
|
|
156
156
|
forHashMethod: boolean = false,
|
|
157
|
+
pageLimits: PageLimits | null = null,
|
|
157
158
|
): string => {
|
|
158
159
|
if (forHashMethod) {
|
|
159
|
-
return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {})})::text) AS "ResultHash"`;
|
|
160
|
+
return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {}, pageLimits)})::text) AS "ResultHash"`;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const variablesWithDefault = {
|
|
@@ -217,6 +218,7 @@ export const generateSQL = (
|
|
|
217
218
|
null,
|
|
218
219
|
index + 1,
|
|
219
220
|
{},
|
|
221
|
+
pageLimits,
|
|
220
222
|
);
|
|
221
223
|
|
|
222
224
|
fieldQueries.push(`'${field.alias || field.name}', ${fieldSQL}`);
|
|
@@ -239,6 +241,7 @@ export const buildSQLForField = (
|
|
|
239
241
|
parentTableAlias: string | null,
|
|
240
242
|
level: number,
|
|
241
243
|
aliasMap: { [alias: string]: string },
|
|
244
|
+
pageLimits: PageLimits | null,
|
|
242
245
|
): string => {
|
|
243
246
|
const tableAlias = generateTableAlias(level);
|
|
244
247
|
|
|
@@ -303,6 +306,7 @@ export const buildSQLForField = (
|
|
|
303
306
|
tableAlias,
|
|
304
307
|
level,
|
|
305
308
|
aliasMap,
|
|
309
|
+
pageLimits,
|
|
306
310
|
),
|
|
307
311
|
([name, selector]) => `'${name}', ${selector}`,
|
|
308
312
|
);
|
|
@@ -310,7 +314,12 @@ export const buildSQLForField = (
|
|
|
310
314
|
const fromClause = `FROM ${dottedQuotedName} ${tableAlias}`;
|
|
311
315
|
|
|
312
316
|
const orderByClause = buildOrderByClausePG(entities, field, tableAlias);
|
|
313
|
-
const paginationClause = buildPaginationClausePG(
|
|
317
|
+
const paginationClause = buildPaginationClausePG(
|
|
318
|
+
field,
|
|
319
|
+
variablesDefinition,
|
|
320
|
+
variables,
|
|
321
|
+
pageLimits,
|
|
322
|
+
);
|
|
314
323
|
|
|
315
324
|
const isArraySelection = !!field.isArray && !withoutArrayWrapper;
|
|
316
325
|
|
|
@@ -28,6 +28,10 @@ export const getDatabasesStructure = async (
|
|
|
28
28
|
) => {
|
|
29
29
|
const entities: TableResolver[] = [];
|
|
30
30
|
const enhancedStoredProcedures: ProcedureResolver[] = [];
|
|
31
|
+
// Taken before the exclusion and auth-table filters below, because that is the
|
|
32
|
+
// only point where a name the config excludes is still visible. Resolving
|
|
33
|
+
// `schema.excludedTables` against `entities` could never succeed.
|
|
34
|
+
const tableNamesByDatabase: Record<string, string[]> = {};
|
|
31
35
|
|
|
32
36
|
// The auth user table holds the password hash and must never be served via
|
|
33
37
|
// the generated API. Its resolver key matches t.schemaName, which is always
|
|
@@ -39,10 +43,17 @@ export const getDatabasesStructure = async (
|
|
|
39
43
|
for (const db of databases) {
|
|
40
44
|
const { tables, storedProcedures } = await fetchStructure(db);
|
|
41
45
|
|
|
46
|
+
// Every other name comparison in this file folds case. This one used not to,
|
|
47
|
+
// and it is the one where a mismatch fails open: excludedTables is a
|
|
48
|
+
// do-not-serve list, so a name spelled in the wrong case served the table.
|
|
49
|
+
const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
|
|
50
|
+
|
|
51
|
+
tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
|
|
52
|
+
|
|
42
53
|
const tablesToAdd = tables
|
|
43
54
|
.reduce<Tables>((acc, t) => {
|
|
44
55
|
if (authUserKey && t.schemaName === authUserKey) return acc;
|
|
45
|
-
if (
|
|
56
|
+
if (excluded.has(t.schemaName.toLowerCase())) return acc;
|
|
46
57
|
|
|
47
58
|
const tableOverride = db.schema?.database[t.schemaName];
|
|
48
59
|
|
|
@@ -150,7 +161,7 @@ export const getDatabasesStructure = async (
|
|
|
150
161
|
enhancedStoredProcedures.push(...storedProceduresToAdd);
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
return { tables: entities, storedProcedures: enhancedStoredProcedures };
|
|
164
|
+
return { tables: entities, storedProcedures: enhancedStoredProcedures, tableNamesByDatabase };
|
|
154
165
|
};
|
|
155
166
|
|
|
156
167
|
export type EntitiesOfRole = {
|
package/src/index.ts
CHANGED
|
@@ -18,10 +18,18 @@ import { getAgent, instantiateAI } from "./singletons/ai";
|
|
|
18
18
|
import { getTokenService, setTokenService } from "./singletons/authentication";
|
|
19
19
|
import { instantiateCronJobs } from "./singletons/cron";
|
|
20
20
|
import { disconnectDatabases, instantiateDatabasesConnections } from "./singletons/databases";
|
|
21
|
+
import { getCacheRedisClient } from "./singletons/cache/redisClient";
|
|
21
22
|
import { env } from "./singletons/env";
|
|
23
|
+
import { setQueryTimeoutMs } from "./singletons/queryTimeout";
|
|
22
24
|
import { instantiateQueues } from "./singletons/queues";
|
|
23
25
|
import { ConfigurationZod } from "./types/zod/configuration";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createMemoryRateLimitStore,
|
|
28
|
+
createRateLimiter,
|
|
29
|
+
createRedisRateLimitStore,
|
|
30
|
+
resolveClientAddress,
|
|
31
|
+
} from "./utils/rateLimit";
|
|
32
|
+
import { S200, S400, S401, S404, S429 } from "./utils/responses";
|
|
25
33
|
import { writeSchema } from "./utils/writeSchema";
|
|
26
34
|
import { logger, configureLogging } from "./logging";
|
|
27
35
|
|
|
@@ -73,6 +81,20 @@ const bootAnalyzedConfiguration = async (env: Env) => {
|
|
|
73
81
|
configureLogging(env.logger);
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
if (env.maxQueryDepth === 0) {
|
|
85
|
+
logger("graphoria").warn(
|
|
86
|
+
"MAX_QUERY_DEPTH=0 disables the query depth limit; one deeply nested query can exhaust the server",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setQueryTimeoutMs(env.queryTimeoutMs);
|
|
91
|
+
|
|
92
|
+
if (env.queryTimeoutMs === 0) {
|
|
93
|
+
logger("graphoria").warn(
|
|
94
|
+
"QUERY_TIMEOUT_MS=0 disables the statement timeout; one slow query can hold a connection and its locks indefinitely",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
if (!env.configuration) {
|
|
77
99
|
throw new Error("Configuration is required to create the GraphQL server");
|
|
78
100
|
}
|
|
@@ -212,19 +234,57 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
212
234
|
|
|
213
235
|
const consoleHtml = await renderPlayground("../playgrounds/console/index.html", {});
|
|
214
236
|
|
|
237
|
+
const rateLimiter = createRateLimiter({
|
|
238
|
+
settings: env.rateLimit,
|
|
239
|
+
anonymousRole: env.anonymousRole,
|
|
240
|
+
permissions: projectConfiguration.auth?.permissions,
|
|
241
|
+
store: () =>
|
|
242
|
+
env.cache.store === "redis"
|
|
243
|
+
? createRedisRateLimitStore(getCacheRedisClient())
|
|
244
|
+
: createMemoryRateLimitStore(),
|
|
245
|
+
});
|
|
246
|
+
|
|
215
247
|
// Helper to get role-based handlers
|
|
216
|
-
const getRoleHandlers = async (req: Request) => {
|
|
248
|
+
const getRoleHandlers = async (req: Request, server?: Bun.Server<unknown>) => {
|
|
217
249
|
const session = await getTokenService().verifyTokenAndGetSession(
|
|
218
250
|
req.headers.get(env.authorizationHeader),
|
|
219
251
|
req.headers.get(env.admin.header),
|
|
220
252
|
);
|
|
253
|
+
|
|
254
|
+
// The limit needs the role, and the role costs a token verification plus a
|
|
255
|
+
// revocation lookup — so it is spent here, once, rather than again inside a
|
|
256
|
+
// wrapper.
|
|
257
|
+
const limit = await rateLimiter?.check(
|
|
258
|
+
session,
|
|
259
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
260
|
+
);
|
|
261
|
+
|
|
221
262
|
return {
|
|
222
263
|
role: session.role!,
|
|
223
264
|
session,
|
|
265
|
+
limit,
|
|
224
266
|
...analyzedConfiguration.roles[session.role!].handlers,
|
|
225
267
|
};
|
|
226
268
|
};
|
|
227
269
|
|
|
270
|
+
/**
|
|
271
|
+
* For the entry points that have no session to key on: the websocket upgrade
|
|
272
|
+
* (the token arrives in `connection_init`, after the upgrade) and MCP. They
|
|
273
|
+
* are keyed by address against the anonymous ceiling.
|
|
274
|
+
*/
|
|
275
|
+
const withRateLimit =
|
|
276
|
+
<T>(handler: (req: BunRequest, server: Bun.Server<unknown>) => T | Promise<T>) =>
|
|
277
|
+
async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
278
|
+
const limit = await rateLimiter?.check(
|
|
279
|
+
null,
|
|
280
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
284
|
+
|
|
285
|
+
return handler(req, server);
|
|
286
|
+
};
|
|
287
|
+
|
|
228
288
|
// Create routes map with all handlers
|
|
229
289
|
const routes: RoutesMap = {};
|
|
230
290
|
|
|
@@ -251,7 +311,8 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
251
311
|
prefixes,
|
|
252
312
|
projectConfiguration,
|
|
253
313
|
analyzedConfiguration,
|
|
254
|
-
|
|
314
|
+
tokenService: getTokenService(),
|
|
315
|
+
rateLimiter,
|
|
255
316
|
}),
|
|
256
317
|
);
|
|
257
318
|
}
|
|
@@ -259,7 +320,7 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
259
320
|
// GraphQL endpoint
|
|
260
321
|
routes[prefixes.graphql] = {
|
|
261
322
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
262
|
-
GET: async (req: Request, server: Bun.Server<unknown>) => {
|
|
323
|
+
GET: withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
|
|
263
324
|
try {
|
|
264
325
|
if (req.headers.get("upgrade") === "websocket") {
|
|
265
326
|
const success = server.upgrade(req, {
|
|
@@ -271,17 +332,19 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
271
332
|
} catch (error) {
|
|
272
333
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
273
334
|
}
|
|
274
|
-
},
|
|
275
|
-
POST: async (req: BunRequest) => {
|
|
335
|
+
}),
|
|
336
|
+
POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
276
337
|
try {
|
|
277
|
-
const { gql, session } = await getRoleHandlers(req);
|
|
338
|
+
const { gql, session, limit } = await getRoleHandlers(req, server);
|
|
339
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
340
|
+
|
|
278
341
|
const { query, variables } = await req.json();
|
|
279
342
|
|
|
280
343
|
if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
|
|
281
344
|
|
|
282
345
|
if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
|
|
283
346
|
|
|
284
|
-
const { hasErrors, validationErrors } = gql.hasErrors(query);
|
|
347
|
+
const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
|
|
285
348
|
|
|
286
349
|
if (hasErrors)
|
|
287
350
|
return new S400({
|
|
@@ -312,7 +375,7 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
312
375
|
|
|
313
376
|
if (mcpEnabled) {
|
|
314
377
|
const mcpPath = `${env.prefix}${env.ai?.mcp?.endpoint ?? projectConfiguration.ai?.endpoint ?? "/ai"}`;
|
|
315
|
-
|
|
378
|
+
const mcpRoutes = createMCPRoutes(analyzedConfiguration, {
|
|
316
379
|
...(env.ai?.mcp ?? {}),
|
|
317
380
|
name: projectConfiguration.name,
|
|
318
381
|
version: projectConfiguration.version,
|
|
@@ -320,15 +383,20 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
320
383
|
adminSecret: env.admin.secret,
|
|
321
384
|
adminSecretHeader: env.admin.header,
|
|
322
385
|
});
|
|
386
|
+
|
|
387
|
+
routes[mcpPath] = Object.fromEntries(
|
|
388
|
+
Object.entries(mcpRoutes).map(([method, handler]) => [method, withRateLimit(handler)]),
|
|
389
|
+
);
|
|
323
390
|
}
|
|
324
391
|
|
|
325
392
|
if (env.ai?.restEnabled) {
|
|
326
393
|
const aiPath = `${env.prefix}/rest${projectConfiguration.ai.endpoint ?? "/ai"}`;
|
|
327
394
|
routes[aiPath] = {
|
|
328
395
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
329
|
-
POST: async (req: BunRequest) => {
|
|
396
|
+
POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
330
397
|
try {
|
|
331
|
-
const { role } = await getRoleHandlers(req);
|
|
398
|
+
const { role, limit } = await getRoleHandlers(req, server);
|
|
399
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
332
400
|
if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
|
|
333
401
|
|
|
334
402
|
const { prompt } = await req.json();
|
|
@@ -347,11 +415,12 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
347
415
|
}
|
|
348
416
|
|
|
349
417
|
// REST API endpoint
|
|
350
|
-
routes[`${prefixes.rest}/*`] = async (req: BunRequest) => {
|
|
418
|
+
routes[`${prefixes.rest}/*`] = async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
351
419
|
if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
|
|
352
420
|
|
|
353
421
|
try {
|
|
354
|
-
const { rest, session } = await getRoleHandlers(req);
|
|
422
|
+
const { rest, session, limit } = await getRoleHandlers(req, server);
|
|
423
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
355
424
|
|
|
356
425
|
const urlParsed = new URL(req.url);
|
|
357
426
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process-wide statement timeout in milliseconds, resolved from
|
|
3
|
+
* `QUERY_TIMEOUT_MS` at boot.
|
|
4
|
+
*
|
|
5
|
+
* It lives here rather than being read from `singletons/env` because the engine
|
|
6
|
+
* connection modules need it, and that module parses `process.env` the moment it
|
|
7
|
+
* is imported — reaching it from an engine would make every unit test that
|
|
8
|
+
* transitively touches a query builder fail without `ADMIN_SECRET` set.
|
|
9
|
+
*
|
|
10
|
+
* `0` disables the bound. That is also the value before boot sets it, so an
|
|
11
|
+
* engine used outside a booted server behaves exactly as it did before timeouts
|
|
12
|
+
* existed.
|
|
13
|
+
*/
|
|
14
|
+
let queryTimeoutMs = 0;
|
|
15
|
+
|
|
16
|
+
export const setQueryTimeoutMs = (ms: number) => {
|
|
17
|
+
queryTimeoutMs = ms;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getQueryTimeoutMs = () => queryTimeoutMs;
|
|
@@ -15,10 +15,26 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
|
|
|
15
15
|
source: EntitySource.TABLE,
|
|
16
16
|
|
|
17
17
|
getSubscriptionKey(context) {
|
|
18
|
-
const { analysis } = context;
|
|
18
|
+
const { analysis, field, session, variables } = context;
|
|
19
19
|
const operation = analysis.operations[0];
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const base = operation.name ?? operation.fields[0]?.name ?? "unknown";
|
|
21
|
+
|
|
22
|
+
// Every part of this is load-bearing. A group shares one poller, and every
|
|
23
|
+
// client joined to it is served that poller's rows — so two callers may
|
|
24
|
+
// only share a key when the query they asked for is the same query,
|
|
25
|
+
// resolved against the same session. The role and the subject carry the row
|
|
26
|
+
// filter; the variables carry everything the caller chose; the selections
|
|
27
|
+
// carry the shape. Two keys that differ where they should not merely cost a
|
|
28
|
+
// second poller, but two that match where they should not is a cross-tenant
|
|
29
|
+
// read.
|
|
30
|
+
return [
|
|
31
|
+
base,
|
|
32
|
+
session.role ?? "",
|
|
33
|
+
session.sub ?? "",
|
|
34
|
+
field.alias ?? "",
|
|
35
|
+
JSON.stringify(field.selections),
|
|
36
|
+
JSON.stringify(variables),
|
|
37
|
+
].join("\u0000");
|
|
22
38
|
},
|
|
23
39
|
|
|
24
40
|
async subscribe(context: SubscriptionContext): Promise<SubscriptionResult> {
|