From 498242865fb5d593f007636717d8b6c75d73511f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 10 Feb 2026 14:24:01 +0000 Subject: [PATCH 01/15] API endpoint for running TRQL queries --- apps/webapp/app/routes/api.v1.query.ts | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 apps/webapp/app/routes/api.v1.query.ts diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts new file mode 100644 index 0000000000..9e481bcd19 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -0,0 +1,76 @@ +import { json } from "@remix-run/server-runtime"; +import { QueryError } from "@internal/clickhouse"; +import { z } from "zod"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { executeQuery, type QueryScope } from "~/services/queryService.server"; +import { logger } from "~/services/logger.server"; +import { rowsToCSV } from "~/utils/dataExport"; + +const BodySchema = z.object({ + query: z.string(), + scope: z.enum(["organization", "project", "environment"]).default("environment"), + period: z.string().nullish(), + from: z.string().nullish(), + to: z.string().nullish(), + format: z.enum(["json", "csv"]).default("json"), +}); + +const { action, loader } = createActionApiRoute( + { + body: BodySchema, + corsStrategy: "all", + }, + async ({ body, authentication }) => { + const { query, scope, period, from, to, format } = body; + const env = authentication.environment; + + const queryResult = await executeQuery({ + name: "api-query", + query, + scope: scope as QueryScope, + organizationId: env.organization.id, + projectId: env.project.id, + environmentId: env.id, + period, + from, + to, + history: { + source: "API", + }, + }); + + if (!queryResult.success) { + const message = + queryResult.error instanceof QueryError + ? queryResult.error.message + : "An unexpected error occurred while executing the query."; + + logger.error("Query API error", { + error: queryResult.error, + query, + }); + + return json({ error: message }, { status: 400 }); + } + + const { result, periodClipped, maxQueryPeriod } = queryResult; + + if (format === "csv") { + const csv = rowsToCSV(result.rows, result.columns); + + return new Response(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": "attachment; filename=query-results.csv", + }, + }); + } + + return json({ + rows: result.rows, + }); + } +); + +export { action, loader }; From 967d6539ed2ceb1bae2e1fbd06c4b3769fef616d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 11 Feb 2026 17:14:53 +0000 Subject: [PATCH 02/15] SDK function for query --- packages/core/src/v3/apiClient/index.ts | 65 +++++++++ packages/core/src/v3/schemas/index.ts | 1 + packages/core/src/v3/schemas/query.ts | 29 ++++ packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/query.ts | 139 ++++++++++++++++++ references/hello-world/src/trigger/query.ts | 148 ++++++++++++++++++++ 6 files changed, 383 insertions(+) create mode 100644 packages/core/src/v3/schemas/query.ts create mode 100644 packages/trigger-sdk/src/v3/query.ts create mode 100644 references/hello-world/src/trigger/query.ts diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 8efbc762ab..e39a50dcfe 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -30,6 +30,9 @@ import { ListScheduleOptions, QueueItem, QueueTypeName, + QueryExecuteRequestBody, + QueryExecuteResponseBody, + QueryExecuteCSVResponseBody, ReplayRunResponse, RescheduleRunRequestBody, ResetIdempotencyKeyResponse, @@ -1406,6 +1409,68 @@ export class ApiClient { ); } + async executeQuery( + query: string, + options?: { + scope?: "environment" | "project" | "organization"; + period?: string; + from?: string; + to?: string; + format?: "json" | "csv"; + }, + requestOptions?: ZodFetchOptions + ): Promise { + const body = { + query, + scope: options?.scope ?? "environment", + period: options?.period, + from: options?.from, + to: options?.to, + format: options?.format ?? "json", + }; + + const format = options?.format ?? "json"; + + if (format === "csv") { + // For CSV, we get a text response + const response = await fetch(`${this.baseUrl}/api/v1/query`, { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errText = await response.text().catch((e) => (e as Error).message); + let errJSON: Object | undefined; + try { + errJSON = JSON.parse(errText) as Object; + } catch { + // ignore + } + const errMessage = errJSON ? undefined : errText; + const responseHeaders = Object.fromEntries(response.headers.entries()); + + throw ApiError.generate(response.status, errJSON, errMessage, responseHeaders); + } + + return await response.text(); + } + + // For JSON, use zodfetch + return zodfetch( + z.object({ + rows: z.array(z.record(z.any())), + }), + `${this.baseUrl}/api/v1/query`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + #getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record) { const headers: Record = { "Content-Type": "application/json", diff --git a/packages/core/src/v3/schemas/index.ts b/packages/core/src/v3/schemas/index.ts index c2b17a72b6..11857d6197 100644 --- a/packages/core/src/v3/schemas/index.ts +++ b/packages/core/src/v3/schemas/index.ts @@ -15,3 +15,4 @@ export * from "./webhooks.js"; export * from "./checkpoints.js"; export * from "./warmStart.js"; export * from "./queues.js"; +export * from "./query.js"; diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts new file mode 100644 index 0000000000..ba2956b058 --- /dev/null +++ b/packages/core/src/v3/schemas/query.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +/** + * Request body schema for executing a query + */ +export const QueryExecuteRequestBody = z.object({ + query: z.string(), + scope: z.enum(["organization", "project", "environment"]).default("environment"), + period: z.string().nullish(), + from: z.string().nullish(), + to: z.string().nullish(), + format: z.enum(["json", "csv"]).default("json"), +}); + +export type QueryExecuteRequestBody = z.infer; + +/** + * Response body schema for JSON format queries + */ +export const QueryExecuteResponseBody = z.object({ + rows: z.array(z.record(z.any())), +}); + +export type QueryExecuteResponseBody = z.infer; + +/** + * Response body type for CSV format queries (returns a string) + */ +export type QueryExecuteCSVResponseBody = string; diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index b2d6247699..43ee41e6e5 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -17,6 +17,7 @@ export * from "./otel.js"; export * from "./schemas.js"; export * from "./heartbeats.js"; export * from "./streams.js"; +export * from "./query.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts new file mode 100644 index 0000000000..772a28826d --- /dev/null +++ b/packages/trigger-sdk/src/v3/query.ts @@ -0,0 +1,139 @@ +import type { + ApiRequestOptions, + QueryExecuteResponseBody, + QueryExecuteCSVResponseBody, +} from "@trigger.dev/core/v3"; +import { apiClientManager, mergeRequestOptions } from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +export type QueryScope = "environment" | "project" | "organization"; +export type QueryFormat = "json" | "csv"; + +/** + * Options for executing a TSQL query + */ +export type QueryOptions = { + /** + * The scope of the query - determines what data is accessible + * - "environment": Current environment only (default) + * - "project": All environments in the project + * - "organization": All projects in the organization + * + * @default "environment" + */ + scope?: "environment" | "project" | "organization"; + + /** + * Time period to query (e.g., "7d", "30d", "1h") + * Cannot be used with `from` or `to` + */ + period?: string; + + /** + * Start of time range (ISO 8601 timestamp) + * Must be used with `to` + */ + from?: string; + + /** + * End of time range (ISO 8601 timestamp) + * Must be used with `from` + */ + to?: string; + + /** + * Response format + * - "json": Returns structured data (default) + * - "csv": Returns CSV string + * + * @default "json" + */ + format?: TFormat; +}; + +/** + * Result type that automatically narrows based on the format option + * @template TFormat - The format type (json or csv) + * @template TRow - The shape of each row in the result set + */ +export type QueryResult< + TFormat extends QueryFormat | undefined = undefined, + TRow extends Record = Record +> = TFormat extends "csv" + ? QueryExecuteCSVResponseBody + : TFormat extends "json" + ? { rows: Array } + : TFormat extends undefined + ? { rows: Array } + : { rows: Array } | QueryExecuteCSVResponseBody; + +/** + * Execute a TSQL query against your Trigger.dev data + * + * @template TFormat - The format of the response (inferred from options) + * @param {string} tsql - The TSQL query string to execute + * @param {QueryOptions} [options] - Optional query configuration + * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration + * @returns A promise that resolves with the query results + * + * @example + * ```typescript + * // Basic query with defaults (environment scope, json format) + * const result = await query.execute("SELECT * FROM runs LIMIT 10"); + * console.log(result.rows); + * + * // Query with custom period + * const lastMonth = await query.execute( + * "SELECT COUNT(*) as count FROM runs", + * { period: "30d" } + * ); + * + * // Query with custom date range + * const januaryRuns = await query.execute( + * "SELECT * FROM runs", + * { + * from: "2025-01-01T00:00:00Z", + * to: "2025-02-01T00:00:00Z" + * } + * ); + * + * // Organization-wide query + * const orgStats = await query.execute( + * "SELECT project, COUNT(*) as count FROM runs GROUP BY project", + * { scope: "organization", period: "7d" } + * ); + * + * // Export as CSV + * const csvData = await query.execute( + * "SELECT * FROM runs", + * { format: "csv", period: "7d" } + * ); + * // csvData is a string containing CSV + * ``` + */ +function execute( + tsql: string, + options?: QueryOptions, + requestOptions?: ApiRequestOptions +): Promise> { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "query.execute()", + icon: "sparkles", + attributes: { + scope: options?.scope ?? "environment", + format: options?.format ?? "json", + }, + }, + requestOptions + ); + + return apiClient.executeQuery(tsql, options, $requestOptions) as Promise>; +} + +export const query = { + execute, +}; diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts new file mode 100644 index 0000000000..6f0fa5ca21 --- /dev/null +++ b/references/hello-world/src/trigger/query.ts @@ -0,0 +1,148 @@ +import { logger, query, task } from "@trigger.dev/sdk"; + +// Simple query example - just the query string, all defaults +export const simpleQueryTask = task({ + id: "simple-query", + run: async () => { + logger.info("Running simple query example"); + + // Simplest usage - uses environment scope, json format, default period + const result = await query.execute("SELECT * FROM runs LIMIT 10"); + + logger.info("Query results", { + rowCount: result.rows.length, + firstRow: result.rows[0], + }); + + // Log all rows + result.rows.forEach((row, index) => { + logger.info(`Row ${index + 1}`, { row }); + }); + + return { + totalRows: result.rows.length, + rows: result.rows, + }; + }, +}); + +// JSON query with all options +export const fullJsonQueryTask = task({ + id: "full-json-query", + run: async () => { + logger.info("Running full JSON query example with all options"); + + // All options specified + const result = await query.execute( + `SELECT + status, + COUNT(*) as count, + AVG(duration) as avg_duration + FROM runs + WHERE status IN ('COMPLETED', 'FAILED') + GROUP BY status`, + { + scope: "environment", // Query current environment only + period: "30d", // Last 30 days of data + format: "json", // JSON format (default) + } + ); + + logger.info("Query completed", { + rowCount: result.rows.length, + }); + + // Log the aggregated results + result.rows.forEach((row) => { + logger.info("Status breakdown", { + status: row.status, + count: row.count, + averageDuration: row.avg_duration, + }); + }); + + return { + summary: result.rows, + }; + }, +}); + +// CSV export example +export const csvQueryTask = task({ + id: "csv-query", + run: async () => { + logger.info("Running CSV query example"); + + // Query with CSV format - returns a string + const csvData = await query.execute( + "SELECT id, status, created_at, duration FROM runs LIMIT 100", + { + scope: "project", // Query all environments in the project + period: "7d", // Last 7 days + format: "csv", // CSV format + } + ); + + logger.info("CSV query completed", { + dataLength: csvData.length, + preview: csvData.substring(0, 200), // Show first 200 chars + }); + + // Count the number of rows (lines - 1 for header) + const lines = csvData.split("\n"); + const rowCount = lines.length - 1; + + logger.info("CSV stats", { + totalRows: rowCount, + headerLine: lines[0], + }); + + return { + csv: csvData, + rowCount, + }; + }, +}); + +// Organization-wide query with date range +export const orgQueryTask = task({ + id: "org-query", + run: async () => { + logger.info("Running organization-wide query"); + + const result = await query.execute( + `SELECT + project, + environment, + COUNT(*) as total_runs, + SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as successful_runs, + SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_runs + FROM runs + GROUP BY project, environment + ORDER BY total_runs DESC`, + { + scope: "organization", // Query across all projects + from: "2025-02-01T00:00:00Z", // Custom date range + to: "2025-02-11T23:59:59Z", + format: "json", + } + ); + + logger.info("Organization query completed", { + projectCount: result.rows.length, + }); + + result.rows.forEach((row) => { + logger.info("Project stats", { + project: row.project, + environment: row.environment, + totalRuns: row.total_runs, + successRate: `${((row.successful_runs / row.total_runs) * 100).toFixed(2)}%`, + }); + }); + + return { + projects: result.rows, + }; + }, +}); From fccfb2ad2cbfd4e38a16889e35c7a9e7761860a6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 12 Feb 2026 11:34:45 +0000 Subject: [PATCH 03/15] Always return an object --- packages/trigger-sdk/src/v3/query.ts | 102 +++++++++++------- references/hello-world/src/trigger/query.ts | 111 ++++++++++++++------ 2 files changed, 141 insertions(+), 72 deletions(-) diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index 772a28826d..72da6bf653 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -12,7 +12,7 @@ export type QueryFormat = "json" | "csv"; /** * Options for executing a TSQL query */ -export type QueryOptions = { +export type QueryOptions = { /** * The scope of the query - determines what data is accessible * - "environment": Current environment only (default) @@ -48,31 +48,33 @@ export type QueryOptions = Record -> = TFormat extends "csv" - ? QueryExecuteCSVResponseBody - : TFormat extends "json" - ? { rows: Array } - : TFormat extends undefined - ? { rows: Array } - : { rows: Array } | QueryExecuteCSVResponseBody; +function execute( + tsql: string, + options: QueryOptions & { format: "csv" }, + requestOptions?: ApiRequestOptions +): Promise<{ format: "csv"; results: string }>; + +/** + * Execute a TSQL query and return typed JSON rows + */ +function execute = Record>( + tsql: string, + options?: Omit | (QueryOptions & { format?: "json" }), + requestOptions?: ApiRequestOptions +): Promise<{ format: "json"; results: Array }>; /** * Execute a TSQL query against your Trigger.dev data * - * @template TFormat - The format of the response (inferred from options) + * @template TRow - The shape of each row in the result set (provide for type safety) * @param {string} tsql - The TSQL query string to execute - * @param {QueryOptions} [options] - Optional query configuration + * @param {QueryOptions} [options] - Optional query configuration * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration * @returns A promise that resolves with the query results * @@ -80,42 +82,55 @@ export type QueryResult< * ```typescript * // Basic query with defaults (environment scope, json format) * const result = await query.execute("SELECT * FROM runs LIMIT 10"); - * console.log(result.rows); + * console.log(result.format); // "json" + * console.log(result.results); // Array> * - * // Query with custom period - * const lastMonth = await query.execute( - * "SELECT COUNT(*) as count FROM runs", - * { period: "30d" } + * // Type-safe query with row type + * type RunRow = { id: string; status: string; duration: number }; + * const typedResult = await query.execute( + * "SELECT id, status, duration FROM runs LIMIT 10" * ); + * typedResult.results.forEach(row => { + * console.log(row.id, row.status); // Fully typed! + * }); * - * // Query with custom date range - * const januaryRuns = await query.execute( - * "SELECT * FROM runs", - * { - * from: "2025-01-01T00:00:00Z", - * to: "2025-02-01T00:00:00Z" - * } + * // Inline type for aggregation query + * const stats = await query.execute<{ status: string; count: number }>( + * "SELECT status, COUNT(*) as count FROM runs GROUP BY status" * ); + * stats.results.forEach(row => { + * console.log(row.status, row.count); // Fully type-safe + * }); * - * // Organization-wide query - * const orgStats = await query.execute( - * "SELECT project, COUNT(*) as count FROM runs GROUP BY project", - * { scope: "organization", period: "7d" } + * // Query with custom period + * const lastMonth = await query.execute( + * "SELECT COUNT(*) as count FROM runs", + * { period: "30d" } * ); + * console.log(lastMonth.results[0].count); // Type-safe access * - * // Export as CSV - * const csvData = await query.execute( + * // Export as CSV - automatically narrowed! + * const csvResult = await query.execute( * "SELECT * FROM runs", * { format: "csv", period: "7d" } * ); - * // csvData is a string containing CSV + * console.log(csvResult.format); // "csv" + * const lines = csvResult.results.split('\n'); // ✓ results is string + * + * // Discriminated union - can check format at runtime + * const dynamicResult = await query.execute("SELECT * FROM runs"); + * if (dynamicResult.format === "json") { + * dynamicResult.results.forEach(row => console.log(row)); // ✓ Typed as array + * } else { + * console.log(dynamicResult.results.length); // ✓ Typed as string + * } * ``` */ -function execute( +function execute = Record>( tsql: string, - options?: QueryOptions, + options?: QueryOptions, requestOptions?: ApiRequestOptions -): Promise> { +): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { const apiClient = apiClientManager.clientOrThrow(); const $requestOptions = mergeRequestOptions( @@ -131,7 +146,14 @@ function execute( requestOptions ); - return apiClient.executeQuery(tsql, options, $requestOptions) as Promise>; + const format = options?.format ?? "json"; + + return apiClient.executeQuery(tsql, options, $requestOptions).then((response) => { + if (typeof response === "string") { + return { format: "csv" as const, results: response }; + } + return { format: "json" as const, results: response.rows }; + }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; } export const query = { diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts index 6f0fa5ca21..d9e40ffc38 100644 --- a/references/hello-world/src/trigger/query.ts +++ b/references/hello-world/src/trigger/query.ts @@ -1,5 +1,13 @@ import { logger, query, task } from "@trigger.dev/sdk"; +// Type definition for a run row +type RunRow = { + id: string; + status: string; + created_at: string; + duration: number; +}; + // Simple query example - just the query string, all defaults export const simpleQueryTask = task({ id: "simple-query", @@ -9,31 +17,51 @@ export const simpleQueryTask = task({ // Simplest usage - uses environment scope, json format, default period const result = await query.execute("SELECT * FROM runs LIMIT 10"); - logger.info("Query results", { - rowCount: result.rows.length, - firstRow: result.rows[0], + logger.info("Query results (untyped)", { + format: result.format, + rowCount: result.results.length, + firstRow: result.results[0], + }); + + // Type-safe query with explicit row type + const typedResult = await query.execute( + "SELECT id, status, created_at, duration FROM runs LIMIT 10" + ); + + logger.info("Query results (typed)", { + format: typedResult.format, + rowCount: typedResult.results.length, + firstRow: typedResult.results[0], }); - // Log all rows - result.rows.forEach((row, index) => { - logger.info(`Row ${index + 1}`, { row }); + // Now we have full type safety on the rows! + typedResult.results.forEach((row, index) => { + logger.info(`Run ${index + 1}`, { + id: row.id, // TypeScript knows this is a string + status: row.status, // TypeScript knows this is a string + duration: row.duration, // TypeScript knows this is a number + }); }); return { - totalRows: result.rows.length, - rows: result.rows, + totalRows: typedResult.results.length, + rows: typedResult.results, }; }, }); -// JSON query with all options +// JSON query with all options and inline type export const fullJsonQueryTask = task({ id: "full-json-query", run: async () => { logger.info("Running full JSON query example with all options"); - // All options specified - const result = await query.execute( + // All options specified with inline type for aggregation + const result = await query.execute<{ + status: string; + count: number; + avg_duration: number; + }>( `SELECT status, COUNT(*) as count, @@ -44,25 +72,26 @@ export const fullJsonQueryTask = task({ { scope: "environment", // Query current environment only period: "30d", // Last 30 days of data - format: "json", // JSON format (default) + // format defaults to "json" } ); logger.info("Query completed", { - rowCount: result.rows.length, + format: result.format, + rowCount: result.results.length, }); - // Log the aggregated results - result.rows.forEach((row) => { + // Log the aggregated results - now fully type-safe! + result.results.forEach((row) => { logger.info("Status breakdown", { - status: row.status, - count: row.count, - averageDuration: row.avg_duration, + status: row.status, // string + count: row.count, // number + averageDuration: row.avg_duration, // number }); }); return { - summary: result.rows, + summary: result.results, }; }, }); @@ -73,8 +102,8 @@ export const csvQueryTask = task({ run: async () => { logger.info("Running CSV query example"); - // Query with CSV format - returns a string - const csvData = await query.execute( + // Query with CSV format - automatically typed as discriminated union! + const result = await query.execute( "SELECT id, status, created_at, duration FROM runs LIMIT 100", { scope: "project", // Query all environments in the project @@ -83,13 +112,15 @@ export const csvQueryTask = task({ } ); + // result.format is "csv" and result.results is automatically typed as string! logger.info("CSV query completed", { - dataLength: csvData.length, - preview: csvData.substring(0, 200), // Show first 200 chars + format: result.format, + dataLength: result.results.length, + preview: result.results.substring(0, 200), // Show first 200 chars }); // Count the number of rows (lines - 1 for header) - const lines = csvData.split("\n"); + const lines = result.results.split("\n"); const rowCount = lines.length - 1; logger.info("CSV stats", { @@ -98,19 +129,29 @@ export const csvQueryTask = task({ }); return { - csv: csvData, + format: result.format, + csv: result.results, rowCount, }; }, }); -// Organization-wide query with date range +// Organization-wide query with date range and type safety export const orgQueryTask = task({ id: "org-query", run: async () => { logger.info("Running organization-wide query"); - const result = await query.execute( + // Define the shape of our aggregated results + type ProjectStats = { + project: string; + environment: string; + total_runs: number; + successful_runs: number; + failed_runs: number; + }; + + const result = await query.execute( `SELECT project, environment, @@ -124,25 +165,31 @@ export const orgQueryTask = task({ scope: "organization", // Query across all projects from: "2025-02-01T00:00:00Z", // Custom date range to: "2025-02-11T23:59:59Z", - format: "json", + // format defaults to "json" } ); logger.info("Organization query completed", { - projectCount: result.rows.length, + format: result.format, + projectCount: result.results.length, }); - result.rows.forEach((row) => { + // Full type safety on aggregated results + result.results.forEach((row) => { + const successRate = (row.successful_runs / row.total_runs) * 100; + logger.info("Project stats", { project: row.project, environment: row.environment, totalRuns: row.total_runs, - successRate: `${((row.successful_runs / row.total_runs) * 100).toFixed(2)}%`, + successfulRuns: row.successful_runs, + failedRuns: row.failed_runs, + successRate: `${successRate.toFixed(2)}%`, }); }); return { - projects: result.rows, + projects: result.results, }; }, }); From 9e7b152370aea2eef4b11279df0232ee19c8622d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 10:46:16 +0000 Subject: [PATCH 04/15] Rework the API/SDK so we can get nice spans --- .../webapp/app/components/runs/v3/RunIcon.tsx | 5 ++- apps/webapp/app/routes/api.v1.query.ts | 12 +++---- packages/core/src/v3/apiClient/index.ts | 32 ++----------------- packages/core/src/v3/schemas/query.ts | 24 ++++++++++---- packages/trigger-sdk/src/v3/query.ts | 23 ++++++------- references/hello-world/src/trigger/query.ts | 24 ++++---------- 6 files changed, 48 insertions(+), 72 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 615def59cd..32e956454d 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -4,6 +4,7 @@ import { InformationCircleIcon, RectangleStackIcon, Squares2X2Icon, + TableCellsIcon, TagIcon, } from "@heroicons/react/20/solid"; import { AttemptIcon } from "~/assets/icons/AttemptIcon"; @@ -81,6 +82,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { return ; case "function": return ; + case "query": + return ; //log levels case "debug": case "log": @@ -110,7 +113,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { case "task-hook-catchError": return ; case "streams": - return ; + return ; } return ; diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index 9e481bcd19..599c792d46 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -58,17 +58,15 @@ const { action, loader } = createActionApiRoute( if (format === "csv") { const csv = rowsToCSV(result.rows, result.columns); - return new Response(csv, { - status: 200, - headers: { - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": "attachment; filename=query-results.csv", - }, + return json({ + format: "csv", + results: csv, }); } return json({ - rows: result.rows, + format: "json", + results: result.rows, }); } ); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index e39a50dcfe..d084958a02 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1419,7 +1419,7 @@ export class ApiClient { format?: "json" | "csv"; }, requestOptions?: ZodFetchOptions - ): Promise { + ): Promise { const body = { query, scope: options?.scope ?? "environment", @@ -1430,37 +1430,9 @@ export class ApiClient { }; const format = options?.format ?? "json"; - - if (format === "csv") { - // For CSV, we get a text response - const response = await fetch(`${this.baseUrl}/api/v1/query`, { - method: "POST", - headers: this.#getHeaders(false), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errText = await response.text().catch((e) => (e as Error).message); - let errJSON: Object | undefined; - try { - errJSON = JSON.parse(errText) as Object; - } catch { - // ignore - } - const errMessage = errJSON ? undefined : errText; - const responseHeaders = Object.fromEntries(response.headers.entries()); - - throw ApiError.generate(response.status, errJSON, errMessage, responseHeaders); - } - - return await response.text(); - } - // For JSON, use zodfetch return zodfetch( - z.object({ - rows: z.array(z.record(z.any())), - }), + QueryExecuteResponseBody, `${this.baseUrl}/api/v1/query`, { method: "POST", diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts index ba2956b058..534745a577 100644 --- a/packages/core/src/v3/schemas/query.ts +++ b/packages/core/src/v3/schemas/query.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { TypeOf, z } from "zod"; /** * Request body schema for executing a query @@ -17,13 +17,25 @@ export type QueryExecuteRequestBody = z.infer; /** * Response body schema for JSON format queries */ -export const QueryExecuteResponseBody = z.object({ - rows: z.array(z.record(z.any())), +export const QueryExecuteJSONResponseBody = z.object({ + format: z.literal("json"), + results: z.array(z.record(z.any())), }); -export type QueryExecuteResponseBody = z.infer; +export type QueryExecuteJSONResponseBody = z.infer; /** - * Response body type for CSV format queries (returns a string) + * Response body type for CSV format queries */ -export type QueryExecuteCSVResponseBody = string; +export const QueryExecuteCSVResponseBody = z.object({ + format: z.literal("json"), + results: z.string(), +}); + +export type QueryExecuteCSVResponseBody = z.infer; + +export const QueryExecuteResponseBody = z.discriminatedUnion("format", [ + QueryExecuteJSONResponseBody, + QueryExecuteCSVResponseBody, +]); +export type QueryExecuteResponseBody = z.infer; diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index 72da6bf653..6b18813d42 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -81,14 +81,14 @@ function execute = Record>( * @example * ```typescript * // Basic query with defaults (environment scope, json format) - * const result = await query.execute("SELECT * FROM runs LIMIT 10"); + * const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10"); * console.log(result.format); // "json" * console.log(result.results); // Array> * * // Type-safe query with row type * type RunRow = { id: string; status: string; duration: number }; * const typedResult = await query.execute( - * "SELECT id, status, duration FROM runs LIMIT 10" + * "SELECT run_id, status, triggered_at FROM runs LIMIT 10" * ); * typedResult.results.forEach(row => { * console.log(row.id, row.status); // Fully typed! @@ -105,14 +105,14 @@ function execute = Record>( * // Query with custom period * const lastMonth = await query.execute( * "SELECT COUNT(*) as count FROM runs", - * { period: "30d" } + * { period: "3d" } * ); * console.log(lastMonth.results[0].count); // Type-safe access * * // Export as CSV - automatically narrowed! * const csvResult = await query.execute( * "SELECT * FROM runs", - * { format: "csv", period: "7d" } + * { format: "csv", period: "1d" } * ); * console.log(csvResult.format); // "csv" * const lines = csvResult.results.split('\n'); // ✓ results is string @@ -133,26 +133,27 @@ function execute = Record>( ): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { const apiClient = apiClientManager.clientOrThrow(); + const format = options?.format ?? "json"; + const $requestOptions = mergeRequestOptions( { tracer, name: "query.execute()", - icon: "sparkles", + icon: "query", attributes: { scope: options?.scope ?? "environment", format: options?.format ?? "json", + query: tsql, + period: options?.period, + from: options?.from, + to: options?.to, }, }, requestOptions ); - const format = options?.format ?? "json"; - return apiClient.executeQuery(tsql, options, $requestOptions).then((response) => { - if (typeof response === "string") { - return { format: "csv" as const, results: response }; - } - return { format: "json" as const, results: response.rows }; + return response; }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; } diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts index d9e40ffc38..3145d9d159 100644 --- a/references/hello-world/src/trigger/query.ts +++ b/references/hello-world/src/trigger/query.ts @@ -4,8 +4,8 @@ import { logger, query, task } from "@trigger.dev/sdk"; type RunRow = { id: string; status: string; - created_at: string; - duration: number; + triggered_at: string; + total_duration: number; }; // Simple query example - just the query string, all defaults @@ -25,7 +25,7 @@ export const simpleQueryTask = task({ // Type-safe query with explicit row type const typedResult = await query.execute( - "SELECT id, status, created_at, duration FROM runs LIMIT 10" + "SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10" ); logger.info("Query results (typed)", { @@ -39,7 +39,7 @@ export const simpleQueryTask = task({ logger.info(`Run ${index + 1}`, { id: row.id, // TypeScript knows this is a string status: row.status, // TypeScript knows this is a string - duration: row.duration, // TypeScript knows this is a number + total_duration: row.total_duration, // TypeScript knows this is a number }); }); @@ -65,7 +65,7 @@ export const fullJsonQueryTask = task({ `SELECT status, COUNT(*) as count, - AVG(duration) as avg_duration + AVG(total_duration) as avg_duration FROM runs WHERE status IN ('COMPLETED', 'FAILED') GROUP BY status`, @@ -104,7 +104,7 @@ export const csvQueryTask = task({ // Query with CSV format - automatically typed as discriminated union! const result = await query.execute( - "SELECT id, status, created_at, duration FROM runs LIMIT 100", + "SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10", { scope: "project", // Query all environments in the project period: "7d", // Last 7 days @@ -116,22 +116,12 @@ export const csvQueryTask = task({ logger.info("CSV query completed", { format: result.format, dataLength: result.results.length, - preview: result.results.substring(0, 200), // Show first 200 chars - }); - - // Count the number of rows (lines - 1 for header) - const lines = result.results.split("\n"); - const rowCount = lines.length - 1; - - logger.info("CSV stats", { - totalRows: rowCount, - headerLine: lines[0], + results: result.results, }); return { format: result.format, csv: result.results, - rowCount, }; }, }); From adccecd72907212e2d20d57991467d102a2c3d3d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 11:29:11 +0000 Subject: [PATCH 05/15] Fix for csv discriminated union --- packages/core/src/v3/schemas/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts index 534745a577..5524d7c8a2 100644 --- a/packages/core/src/v3/schemas/query.ts +++ b/packages/core/src/v3/schemas/query.ts @@ -28,7 +28,7 @@ export type QueryExecuteJSONResponseBody = z.infer Date: Sat, 14 Feb 2026 15:09:38 +0000 Subject: [PATCH 06/15] Fix for bad types in the example --- packages/trigger-sdk/src/v3/query.ts | 2 -- references/hello-world/src/trigger/query.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index 6b18813d42..f2250d5f6a 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -133,8 +133,6 @@ function execute = Record>( ): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { const apiClient = apiClientManager.clientOrThrow(); - const format = options?.format ?? "json"; - const $requestOptions = mergeRequestOptions( { tracer, diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts index 3145d9d159..3cdcffa6bb 100644 --- a/references/hello-world/src/trigger/query.ts +++ b/references/hello-world/src/trigger/query.ts @@ -2,7 +2,7 @@ import { logger, query, task } from "@trigger.dev/sdk"; // Type definition for a run row type RunRow = { - id: string; + run_id: string; status: string; triggered_at: string; total_duration: number; @@ -37,7 +37,7 @@ export const simpleQueryTask = task({ // Now we have full type safety on the rows! typedResult.results.forEach((row, index) => { logger.info(`Run ${index + 1}`, { - id: row.id, // TypeScript knows this is a string + run_id: row.run_id, // TypeScript knows this is a string status: row.status, // TypeScript knows this is a string total_duration: row.total_duration, // TypeScript knows this is a number }); From 8c10ef83001a757cfcdf28c58a7c59578dd8ff21 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 14 Feb 2026 15:42:17 +0000 Subject: [PATCH 07/15] Nice type support --- .../app/components/runs/v3/TaskRunStatus.tsx | 24 +-- packages/core/src/v3/schemas/query.ts | 168 ++++++++++++++++++ packages/trigger-sdk/src/v3/query.ts | 7 +- references/hello-world/src/trigger/query.ts | 82 ++++----- 4 files changed, 208 insertions(+), 73 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index fd39ce15d5..73363e0daa 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -12,6 +12,7 @@ import { XCircleIcon, } from "@heroicons/react/20/solid"; import type { TaskRunStatus } from "@trigger.dev/database"; +import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3"; import assertNever from "assert-never"; import { HourglassIcon } from "lucide-react"; import { TimedOutIcon } from "~/assets/icons/TimedOutIcon"; @@ -248,26 +249,9 @@ export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRun return result[0] as TaskRunStatus; } -export const runFriendlyStatus = [ - "Delayed", - "Queued", - "Pending version", - "Dequeued", - "Executing", - "Waiting", - "Reattempting", - "Paused", - "Canceled", - "Interrupted", - "Completed", - "Failed", - "System failure", - "Crashed", - "Expired", - "Timed out", -] as const; - -export type RunFriendlyStatus = (typeof runFriendlyStatus)[number]; +// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3 +// and re-exported here for backward compatibility. +export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3"; /** * Check if a value is a valid TaskRunStatus diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts index 5524d7c8a2..3fac853ea6 100644 --- a/packages/core/src/v3/schemas/query.ts +++ b/packages/core/src/v3/schemas/query.ts @@ -1,4 +1,7 @@ import { TypeOf, z } from "zod"; +import type { MachinePresetName } from "./common.js"; +import type { RuntimeEnvironmentType } from "./common.js"; +import type { IdempotencyKeyScope } from "../idempotency-key-catalog/catalog.js"; /** * Request body schema for executing a query @@ -39,3 +42,168 @@ export const QueryExecuteResponseBody = z.discriminatedUnion("format", [ QueryExecuteCSVResponseBody, ]); export type QueryExecuteResponseBody = z.infer; + +// --------------------------------------------------------------------------- +// Query table row types +// --------------------------------------------------------------------------- + +/** + * User-facing friendly run status values returned by the query system. + */ +export const runFriendlyStatus = [ + "Delayed", + "Queued", + "Pending version", + "Dequeued", + "Executing", + "Waiting", + "Reattempting", + "Paused", + "Canceled", + "Interrupted", + "Completed", + "Failed", + "System failure", + "Crashed", + "Expired", + "Timed out", +] as const; + +export type RunFriendlyStatus = (typeof runFriendlyStatus)[number]; + +/** + * Full row type for the `runs` query table. + * + * Each property corresponds to a column available in TSQL queries against the + * `runs` table. Types are mapped from the underlying ClickHouse column types: + * + * - `String` → `string` + * - `UInt8` / `UInt32` / `Int64` / `Float64` → `number` + * - `DateTime64` → `string` + * - `Nullable(X)` → `X | null` + * - `Array(String)` → `string[]` + * - `JSON` → `Record` + * - `LowCardinality(String)` with constrained values → narrow union type + */ +export interface RunsTableRow { + /** Unique run ID (e.g. `run_cm1a2b3c4d5e6f7g8h9i`) */ + run_id: string; + /** Environment slug */ + environment: string; + /** Project reference (e.g. `proj_howcnaxbfxdmwmxazktx`) */ + project: string; + /** Environment type */ + environment_type: RuntimeEnvironmentType; + /** Number of attempts (starts at 1) */ + attempt_count: number; + /** Run status (friendly name) */ + status: RunFriendlyStatus; + /** Whether the run is finished (0 or 1) */ + is_finished: number; + /** Task identifier/slug */ + task_identifier: string; + /** Queue name */ + queue: string; + /** Batch ID (if part of a batch), or `null` */ + batch_id: string | null; + /** Root run ID (for child runs), or `null` */ + root_run_id: string | null; + /** Parent run ID (for child runs), or `null` */ + parent_run_id: string | null; + /** Nesting depth (0 for root runs) */ + depth: number; + /** Whether this is a root run (0 or 1) */ + is_root_run: number; + /** Whether this is a child run (0 or 1) */ + is_child_run: number; + /** Idempotency key */ + idempotency_key: string; + /** Idempotency key scope (empty string means no idempotency key is set) */ + idempotency_key_scope: IdempotencyKeyScope | ""; + /** Region, or `null` */ + region: string | null; + /** When the run was triggered (ISO 8601) */ + triggered_at: string; + /** When the run was queued, or `null` */ + queued_at: string | null; + /** When the run was dequeued, or `null` */ + dequeued_at: string | null; + /** When execution began, or `null` */ + executed_at: string | null; + /** When the run completed, or `null` */ + completed_at: string | null; + /** Delayed execution until this time, or `null` */ + delay_until: string | null; + /** Whether the run had a delay (0 or 1) */ + has_delay: number; + /** When the run expired, or `null` */ + expired_at: string | null; + /** TTL string for expiration (e.g. `"10m"`) */ + ttl: string; + /** Time from execution start to completion in ms, or `null` */ + execution_duration: number | null; + /** Time from trigger to completion in ms, or `null` */ + total_duration: number | null; + /** Time from queued to dequeued in ms, or `null` */ + queued_duration: number | null; + /** Compute usage duration in ms */ + usage_duration: number; + /** Compute cost in dollars */ + compute_cost: number; + /** Invocation cost in dollars */ + invocation_cost: number; + /** Total cost in dollars (compute + invocation) */ + total_cost: number; + /** The data returned from the task */ + output: Record; + /** Error data if the run failed */ + error: Record; + /** Tags added to the run */ + tags: string[]; + /** Code version in reverse date format (e.g. `"20240115.1"`) */ + task_version: string; + /** SDK package version */ + sdk_version: string; + /** CLI package version */ + cli_version: string; + /** Machine preset the run executed on */ + machine: MachinePresetName; + /** Whether this is a test run (0 or 1) */ + is_test: number; + /** Concurrency key passed when triggering */ + concurrency_key: string; + /** Max allowed compute duration in seconds, or `null` */ + max_duration: number | null; + /** Bulk action group IDs that operated on this run */ + bulk_action_group_ids: string[]; +} + +/** @internal Map of query table names to their full row types */ +type QueryTableMap = { + runs: RunsTableRow; +}; + +/** + * Type helper for Query results. + * + * @example + * ```typescript + * // All columns from the runs table + * type AllRuns = QueryTable<"runs">; + * + * // Only specific columns + * type MyResult = QueryTable<"runs", "status" | "run_id">; + * + * // Access a single field type + * type Status = QueryTable<"runs">["status"]; // RunFriendlyStatus + * + * // Use with query.execute + * const result = await query.execute>( + * "SELECT status, run_id FROM runs" + * ); + * ``` + */ +export type QueryTable< + TTable extends keyof QueryTableMap, + TColumns extends keyof QueryTableMap[TTable] = keyof QueryTableMap[TTable], +> = Pick; diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index f2250d5f6a..a7e5addc35 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -1,11 +1,14 @@ import type { ApiRequestOptions, + Prettify, QueryExecuteResponseBody, QueryExecuteCSVResponseBody, } from "@trigger.dev/core/v3"; import { apiClientManager, mergeRequestOptions } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; +export type { QueryTable, RunsTableRow, RunFriendlyStatus } from "@trigger.dev/core/v3"; + export type QueryScope = "environment" | "project" | "organization"; export type QueryFormat = "json" | "csv"; @@ -21,7 +24,7 @@ export type QueryOptions = { * * @default "environment" */ - scope?: "environment" | "project" | "organization"; + scope?: QueryScope; /** * Time period to query (e.g., "7d", "30d", "1h") @@ -67,7 +70,7 @@ function execute = Record>( tsql: string, options?: Omit | (QueryOptions & { format?: "json" }), requestOptions?: ApiRequestOptions -): Promise<{ format: "json"; results: Array }>; +): Promise<{ format: "json"; results: Array> }>; /** * Execute a TSQL query against your Trigger.dev data diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts index 3cdcffa6bb..50bf60668b 100644 --- a/references/hello-world/src/trigger/query.ts +++ b/references/hello-world/src/trigger/query.ts @@ -1,12 +1,5 @@ import { logger, query, task } from "@trigger.dev/sdk"; - -// Type definition for a run row -type RunRow = { - run_id: string; - status: string; - triggered_at: string; - total_duration: number; -}; +import type { QueryTable } from "@trigger.dev/sdk"; // Simple query example - just the query string, all defaults export const simpleQueryTask = task({ @@ -23,10 +16,10 @@ export const simpleQueryTask = task({ firstRow: result.results[0], }); - // Type-safe query with explicit row type - const typedResult = await query.execute( - "SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10" - ); + // Type-safe query using QueryTable with specific columns + const typedResult = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at" | "total_duration"> + >("SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10"); logger.info("Query results (typed)", { format: typedResult.format, @@ -34,12 +27,12 @@ export const simpleQueryTask = task({ firstRow: typedResult.results[0], }); - // Now we have full type safety on the rows! + // Full type safety on the rows - status is narrowly typed! typedResult.results.forEach((row, index) => { logger.info(`Run ${index + 1}`, { - run_id: row.run_id, // TypeScript knows this is a string - status: row.status, // TypeScript knows this is a string - total_duration: row.total_duration, // TypeScript knows this is a number + run_id: row.run_id, // string + status: row.status, // RunFriendlyStatus ("Completed" | "Failed" | ...) + total_duration: row.total_duration, // number | null }); }); @@ -50,13 +43,14 @@ export const simpleQueryTask = task({ }, }); -// JSON query with all options and inline type +// JSON query with all options - aggregation queries use inline types export const fullJsonQueryTask = task({ id: "full-json-query", run: async () => { logger.info("Running full JSON query example with all options"); - // All options specified with inline type for aggregation + // For aggregation queries, use inline types since the result shape + // doesn't match a table row. For non-aggregated queries, use QueryTable. const result = await query.execute<{ status: string; count: number; @@ -67,7 +61,7 @@ export const fullJsonQueryTask = task({ COUNT(*) as count, AVG(total_duration) as avg_duration FROM runs - WHERE status IN ('COMPLETED', 'FAILED') + WHERE status IN ('Completed', 'Failed') GROUP BY status`, { scope: "environment", // Query current environment only @@ -126,60 +120,46 @@ export const csvQueryTask = task({ }, }); -// Organization-wide query with date range and type safety +// Organization-wide query with QueryTable for full row access export const orgQueryTask = task({ id: "org-query", run: async () => { logger.info("Running organization-wide query"); - // Define the shape of our aggregated results - type ProjectStats = { - project: string; - environment: string; - total_runs: number; - successful_runs: number; - failed_runs: number; - }; - - const result = await query.execute( - `SELECT - project, - environment, - COUNT(*) as total_runs, - SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as successful_runs, - SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_runs + // Use QueryTable to get typed rows for specific columns + const result = await query.execute< + QueryTable<"runs", "run_id" | "project" | "environment" | "status" | "task_identifier" | "machine"> + >( + `SELECT run_id, project, environment, status, task_identifier, machine FROM runs - GROUP BY project, environment - ORDER BY total_runs DESC`, + ORDER BY triggered_at DESC + LIMIT 50`, { scope: "organization", // Query across all projects from: "2025-02-01T00:00:00Z", // Custom date range to: "2025-02-11T23:59:59Z", - // format defaults to "json" } ); logger.info("Organization query completed", { format: result.format, - projectCount: result.results.length, + runCount: result.results.length, }); - // Full type safety on aggregated results + // Fully typed - status is RunFriendlyStatus, machine is MachinePresetName result.results.forEach((row) => { - const successRate = (row.successful_runs / row.total_runs) * 100; - - logger.info("Project stats", { - project: row.project, - environment: row.environment, - totalRuns: row.total_runs, - successfulRuns: row.successful_runs, - failedRuns: row.failed_runs, - successRate: `${successRate.toFixed(2)}%`, + logger.info("Run info", { + runId: row.run_id, // string + project: row.project, // string + environment: row.environment, // string + status: row.status, // "Completed" | "Failed" | "Executing" | ... + task: row.task_identifier, // string + machine: row.machine, // "micro" | "small-1x" | "small-2x" | ... }); }); return { - projects: result.results, + runs: result.results, }; }, }); From c26723a9cc14ad40142d4eebe5cd4d1bd189089c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 14 Feb 2026 19:48:38 +0000 Subject: [PATCH 08/15] Do a 500 error if it's not a QueryError --- apps/webapp/app/routes/api.v1.query.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index 599c792d46..f2a792ce4f 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -50,7 +50,10 @@ const { action, loader } = createActionApiRoute( query, }); - return json({ error: message }, { status: 400 }); + return json( + { error: message }, + { status: queryResult.error instanceof QueryError ? 400 : 500 } + ); } const { result, periodClipped, maxQueryPeriod } = queryResult; From c6b662b782ecedc37622e656fce6267e32021bc7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 14 Feb 2026 19:49:27 +0000 Subject: [PATCH 09/15] Removed unused format var --- packages/core/src/v3/apiClient/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index d084958a02..428493b71e 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1429,8 +1429,6 @@ export class ApiClient { format: options?.format ?? "json", }; - const format = options?.format ?? "json"; - // For JSON, use zodfetch return zodfetch( QueryExecuteResponseBody, `${this.baseUrl}/api/v1/query`, From f105b8f4ce9a02ecd2f31c863d4654c7768c3c60 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 14 Feb 2026 21:29:22 +0000 Subject: [PATCH 10/15] Fix for wrong type --- packages/core/src/v3/schemas/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/v3/schemas/query.ts b/packages/core/src/v3/schemas/query.ts index 3fac853ea6..44b771e5f1 100644 --- a/packages/core/src/v3/schemas/query.ts +++ b/packages/core/src/v3/schemas/query.ts @@ -25,7 +25,7 @@ export const QueryExecuteJSONResponseBody = z.object({ results: z.array(z.record(z.any())), }); -export type QueryExecuteJSONResponseBody = z.infer; +export type QueryExecuteJSONResponseBody = z.infer; /** * Response body type for CSV format queries From 885d17e30cda9d8465991d653121d85739db1e1e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 15 Feb 2026 12:53:43 +0000 Subject: [PATCH 11/15] Improved the JSDocs --- packages/trigger-sdk/src/v3/query.ts | 82 +++++++++++++--------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index a7e5addc35..9473f8427f 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -13,7 +13,7 @@ export type QueryScope = "environment" | "project" | "organization"; export type QueryFormat = "json" | "csv"; /** - * Options for executing a TSQL query + * Options for executing a TRQL query */ export type QueryOptions = { /** @@ -55,49 +55,52 @@ export type QueryOptions = { }; /** - * Execute a TSQL query and export as CSV + * Execute a TRQL query and get the results as a CSV string. + * + * @param {string} query - The TRQL query string to execute + * @param {QueryOptions & { format: "csv" }} options - Query options with `format: "csv"` + * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration + * @returns A promise resolving to `{ format: "csv", results: string }` where `results` is the raw CSV text + * + * @example + * ```typescript + * const csvResult = await query.execute( + * "SELECT run_id, status, triggered_at FROM runs", + * { format: "csv", period: "7d" } + * ); + * const lines = csvResult.results.split('\n'); + * ``` */ function execute( - tsql: string, + query: string, options: QueryOptions & { format: "csv" }, requestOptions?: ApiRequestOptions ): Promise<{ format: "csv"; results: string }>; /** - * Execute a TSQL query and return typed JSON rows - */ -function execute = Record>( - tsql: string, - options?: Omit | (QueryOptions & { format?: "json" }), - requestOptions?: ApiRequestOptions -): Promise<{ format: "json"; results: Array> }>; - -/** - * Execute a TSQL query against your Trigger.dev data + * Execute a TRQL query and return typed JSON rows. * - * @template TRow - The shape of each row in the result set (provide for type safety) - * @param {string} tsql - The TSQL query string to execute + * @template TRow - The shape of each row in the result set. Use {@link QueryTable} for type-safe column access (e.g. `QueryTable<"runs", "status" | "run_id">`) + * @param {string} query - The TRQL query string to execute * @param {QueryOptions} [options] - Optional query configuration * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration - * @returns A promise that resolves with the query results + * @returns A promise resolving to `{ format: "json", results: Array }` * * @example * ```typescript * // Basic query with defaults (environment scope, json format) * const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10"); - * console.log(result.format); // "json" * console.log(result.results); // Array> * - * // Type-safe query with row type - * type RunRow = { id: string; status: string; duration: number }; - * const typedResult = await query.execute( + * // Type-safe query using QueryTable with specific columns + * const typedResult = await query.execute>( * "SELECT run_id, status, triggered_at FROM runs LIMIT 10" * ); * typedResult.results.forEach(row => { - * console.log(row.id, row.status); // Fully typed! + * console.log(row.run_id, row.status); // Fully typed! * }); * - * // Inline type for aggregation query + * // Inline type for aggregation queries * const stats = await query.execute<{ status: string; count: number }>( * "SELECT status, COUNT(*) as count FROM runs GROUP BY status" * ); @@ -105,32 +108,23 @@ function execute = Record>( * console.log(row.status, row.count); // Fully type-safe * }); * - * // Query with custom period - * const lastMonth = await query.execute( + * // Query with a custom time period + * const recent = await query.execute( * "SELECT COUNT(*) as count FROM runs", * { period: "3d" } * ); - * console.log(lastMonth.results[0].count); // Type-safe access - * - * // Export as CSV - automatically narrowed! - * const csvResult = await query.execute( - * "SELECT * FROM runs", - * { format: "csv", period: "1d" } - * ); - * console.log(csvResult.format); // "csv" - * const lines = csvResult.results.split('\n'); // ✓ results is string - * - * // Discriminated union - can check format at runtime - * const dynamicResult = await query.execute("SELECT * FROM runs"); - * if (dynamicResult.format === "json") { - * dynamicResult.results.forEach(row => console.log(row)); // ✓ Typed as array - * } else { - * console.log(dynamicResult.results.length); // ✓ Typed as string - * } + * console.log(recent.results[0].count); * ``` */ function execute = Record>( - tsql: string, + query: string, + options?: Omit | (QueryOptions & { format?: "json" }), + requestOptions?: ApiRequestOptions +): Promise<{ format: "json"; results: Array> }>; + +// Implementation +function execute = Record>( + query: string, options?: QueryOptions, requestOptions?: ApiRequestOptions ): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { @@ -144,7 +138,7 @@ function execute = Record>( attributes: { scope: options?.scope ?? "environment", format: options?.format ?? "json", - query: tsql, + query, period: options?.period, from: options?.from, to: options?.to, @@ -153,7 +147,7 @@ function execute = Record>( requestOptions ); - return apiClient.executeQuery(tsql, options, $requestOptions).then((response) => { + return apiClient.executeQuery(query, options, $requestOptions).then((response) => { return response; }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; } From 00a7fa793aad52c92dfc05ca1a53ed5586ed2a8b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 15 Feb 2026 12:58:20 +0000 Subject: [PATCH 12/15] Changeset --- .changeset/afraid-gorillas-jump.md | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/afraid-gorillas-jump.md diff --git a/.changeset/afraid-gorillas-jump.md b/.changeset/afraid-gorillas-jump.md new file mode 100644 index 0000000000..c011239e0d --- /dev/null +++ b/.changeset/afraid-gorillas-jump.md @@ -0,0 +1,34 @@ +--- +"@trigger.dev/sdk": patch +--- + +Added `query.execute()` which lets you query your Trigger.dev data using TRQL (Trigger Query Language) and returns results as typed JSON rows or CSV. It supports configurable scope (environment, project, or organization), time filtering via `period` or `from`/`to` ranges, and a `format` option for JSON or CSV output. + +```typescript +import { query } from "@trigger.dev/sdk"; +import type { QueryTable } from "@trigger.dev/sdk"; + +// Basic untyped query +const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10"); + +// Type-safe query using QueryTable to pick specific columns +const typedResult = await query.execute>( + "SELECT run_id, status, triggered_at FROM runs LIMIT 10" +); +typedResult.results.forEach(row => { + console.log(row.run_id, row.status); // Fully typed +}); + +// Aggregation query with inline types +const stats = await query.execute<{ status: string; count: number }>( + "SELECT status, COUNT(*) as count FROM runs GROUP BY status", + { scope: "project", period: "30d" } +); + +// CSV export +const csv = await query.execute( + "SELECT run_id, status FROM runs", + { format: "csv", period: "7d" } +); +console.log(csv.results); // Raw CSV string +``` From c72e366baf608cfca412a74106891338a7ae6fb9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 15 Feb 2026 13:01:23 +0000 Subject: [PATCH 13/15] Unrelated: fix for unused RunIcon line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It wasn’t being returned --- apps/webapp/app/components/runs/v3/RunIcon.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index 32e956454d..846d7cae0a 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -48,8 +48,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { ) { return ; } - - ; } if (!name) return ; From c02b56473bb477f0b5f02ae80c9a367957e84eed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 15 Feb 2026 13:11:38 +0000 Subject: [PATCH 14/15] Change version of @trigger.dev/sdk to minor Added functionality to query Trigger.dev data with TRQL. --- .changeset/afraid-gorillas-jump.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/afraid-gorillas-jump.md b/.changeset/afraid-gorillas-jump.md index c011239e0d..1734f05349 100644 --- a/.changeset/afraid-gorillas-jump.md +++ b/.changeset/afraid-gorillas-jump.md @@ -1,5 +1,5 @@ --- -"@trigger.dev/sdk": patch +"@trigger.dev/sdk": minor --- Added `query.execute()` which lets you query your Trigger.dev data using TRQL (Trigger Query Language) and returns results as typed JSON rows or CSV. It supports configurable scope (environment, project, or organization), time filtering via `period` or `from`/`to` ranges, and a `format` option for JSON or CSV output. From ce9e37c1b47e354711dc721f2bbcb2e459914c61 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 15 Feb 2026 13:29:58 +0000 Subject: [PATCH 15/15] from and to can be Date or timestamps --- packages/trigger-sdk/src/v3/query.ts | 49 ++++++++++---- references/hello-world/src/trigger/query.ts | 72 +++++++++++++-------- 2 files changed, 83 insertions(+), 38 deletions(-) diff --git a/packages/trigger-sdk/src/v3/query.ts b/packages/trigger-sdk/src/v3/query.ts index 9473f8427f..8d86416b27 100644 --- a/packages/trigger-sdk/src/v3/query.ts +++ b/packages/trigger-sdk/src/v3/query.ts @@ -33,16 +33,16 @@ export type QueryOptions = { period?: string; /** - * Start of time range (ISO 8601 timestamp) - * Must be used with `to` + * Start of time range as a Date object or Unix timestamp in milliseconds. + * Must be used with `to`. */ - from?: string; + from?: Date | number; /** - * End of time range (ISO 8601 timestamp) - * Must be used with `from` + * End of time range as a Date object or Unix timestamp in milliseconds. + * Must be used with `from`. */ - to?: string; + to?: Date | number; /** * Response format @@ -130,6 +130,9 @@ function execute = Record>( ): Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }> { const apiClient = apiClientManager.clientOrThrow(); + const from = dateToISOString(options?.from); + const to = dateToISOString(options?.to); + const $requestOptions = mergeRequestOptions( { tracer, @@ -140,16 +143,40 @@ function execute = Record>( format: options?.format ?? "json", query, period: options?.period, - from: options?.from, - to: options?.to, + from, + to, }, }, requestOptions ); - return apiClient.executeQuery(query, options, $requestOptions).then((response) => { - return response; - }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; + return apiClient + .executeQuery( + query, + { + scope: options?.scope, + period: options?.period, + from, + to, + format: options?.format, + }, + $requestOptions + ) + .then((response) => { + return response; + }) as Promise<{ format: "json"; results: Array } | { format: "csv"; results: string }>; +} + +function dateToISOString(date: Date | number | undefined): string | undefined { + if (date === undefined) { + return undefined; + } + + if (date instanceof Date) { + return date.toISOString(); + } + + return new Date(date).toISOString(); } export const query = { diff --git a/references/hello-world/src/trigger/query.ts b/references/hello-world/src/trigger/query.ts index 50bf60668b..bad268470c 100644 --- a/references/hello-world/src/trigger/query.ts +++ b/references/hello-world/src/trigger/query.ts @@ -1,44 +1,62 @@ import { logger, query, task } from "@trigger.dev/sdk"; import type { QueryTable } from "@trigger.dev/sdk"; -// Simple query example - just the query string, all defaults +// Simple query example - tests different from/to formats export const simpleQueryTask = task({ id: "simple-query", run: async () => { logger.info("Running simple query example"); - // Simplest usage - uses environment scope, json format, default period - const result = await query.execute("SELECT * FROM runs LIMIT 10"); - - logger.info("Query results (untyped)", { - format: result.format, - rowCount: result.results.length, - firstRow: result.results[0], + // 1. Default: no from/to, uses default period + const defaultResult = await query.execute("SELECT * FROM runs LIMIT 5"); + logger.info("Default (no from/to)", { + rowCount: defaultResult.results.length, + firstRow: defaultResult.results[0], }); - // Type-safe query using QueryTable with specific columns - const typedResult = await query.execute< - QueryTable<"runs", "run_id" | "status" | "triggered_at" | "total_duration"> - >("SELECT run_id, status, triggered_at, total_duration FROM runs LIMIT 10"); + // 2. Using Date objects for from/to + const withDates = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: new Date("2025-01-01T00:00:00Z"), + to: new Date(), + }); + logger.info("With Date objects", { + rowCount: withDates.results.length, + firstRow: withDates.results[0], + }); - logger.info("Query results (typed)", { - format: typedResult.format, - rowCount: typedResult.results.length, - firstRow: typedResult.results[0], + // 3. Using Unix timestamps in milliseconds (Date.now() returns ms) + const now = Date.now(); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + const withTimestamps = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: sevenDaysAgo, + to: now, + }); + logger.info("With Unix timestamps (ms)", { + rowCount: withTimestamps.results.length, + firstRow: withTimestamps.results[0], }); - // Full type safety on the rows - status is narrowly typed! - typedResult.results.forEach((row, index) => { - logger.info(`Run ${index + 1}`, { - run_id: row.run_id, // string - status: row.status, // RunFriendlyStatus ("Completed" | "Failed" | ...) - total_duration: row.total_duration, // number | null - }); + // 4. Mixing Date and number + const mixed = await query.execute< + QueryTable<"runs", "run_id" | "status" | "triggered_at"> + >("SELECT run_id, status, triggered_at FROM runs LIMIT 5", { + from: new Date("2025-01-01"), + to: Date.now(), + }); + logger.info("Mixed Date + timestamp", { + rowCount: mixed.results.length, + firstRow: mixed.results[0], }); return { - totalRows: typedResult.results.length, - rows: typedResult.results, + defaultRows: defaultResult.results.length, + dateRows: withDates.results.length, + timestampRows: withTimestamps.results.length, + mixedRows: mixed.results.length, }; }, }); @@ -136,8 +154,8 @@ export const orgQueryTask = task({ LIMIT 50`, { scope: "organization", // Query across all projects - from: "2025-02-01T00:00:00Z", // Custom date range - to: "2025-02-11T23:59:59Z", + from: new Date("2025-02-01T00:00:00Z"), // Custom date range + to: new Date("2025-02-11T23:59:59Z"), } );