chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,128 @@
import { getAdminClickhouse } from "~/services/clickhouse/clickhouseFactory.server";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
export type MissingLlmModel = {
model: string;
system: string;
count: number;
};
export async function getMissingLlmModels(
opts: {
lookbackHours?: number;
} = {}
): Promise<MissingLlmModel[]> {
const lookbackHours = opts.lookbackHours ?? 24;
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
const adminClickhouse = getAdminClickhouse();
// queryBuilderFast returns a factory function — call it to get the builder
const createBuilder = adminClickhouse.reader.queryBuilderFast<{
model: string;
system: string;
cnt: string;
}>({
name: "missingLlmModels",
table: "trigger_dev.task_events_v2",
columns: [
{ name: "model", expression: "attributes.gen_ai.response.model.:String" },
{ name: "system", expression: "attributes.gen_ai.system.:String" },
{ name: "cnt", expression: "count()" },
],
});
const qb = createBuilder();
// Partition pruning on inserted_at (partition key is toDate(inserted_at))
qb.where("inserted_at >= {since: DateTime64(3)}", {
since: formatDateTime(since),
});
// Only spans that have a model set
qb.where("attributes.gen_ai.response.model.:String != {empty: String}", { empty: "" });
// Only spans that were NOT cost-enriched (trigger.llm.total_cost is NULL)
qb.where("attributes.trigger.llm.total_cost.:Float64 IS NULL", {});
// Only completed spans
qb.where("kind = {kind: String}", { kind: "SPAN" });
qb.where("status = {status: String}", { status: "OK" });
qb.groupBy("model, system");
qb.orderBy("cnt DESC");
qb.limit(100);
const [err, rows] = await qb.execute();
if (err) {
throw err;
}
if (!rows) {
return [];
}
const candidates = rows
.filter((r) => r.model)
.map((r) => ({
model: r.model,
system: r.system,
count: parseInt(r.cnt, 10),
}));
if (candidates.length === 0) return [];
// Filter out models that now have pricing in the database (added after spans were inserted).
// The registry's match() handles prefix stripping for gateway/openrouter models.
if (!llmPricingRegistry || !llmPricingRegistry.isLoaded) return candidates;
const registry = llmPricingRegistry;
return candidates.filter((c) => !registry.match(c.model));
}
export type MissingModelSample = {
span_id: string;
run_id: string;
message: string;
attributes_text: string;
duration: string;
start_time: string;
};
export async function getMissingModelSamples(opts: {
model: string;
lookbackHours?: number;
limit?: number;
}): Promise<MissingModelSample[]> {
const lookbackHours = opts.lookbackHours ?? 24;
const limit = opts.limit ?? 10;
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
const adminClickhouse = getAdminClickhouse();
const createBuilder = adminClickhouse.reader.queryBuilderFast<MissingModelSample>({
name: "missingModelSamples",
table: "trigger_dev.task_events_v2",
columns: ["span_id", "run_id", "message", "attributes_text", "duration", "start_time"],
});
const qb = createBuilder();
qb.where("inserted_at >= {since: DateTime64(3)}", { since: formatDateTime(since) });
qb.where("attributes.gen_ai.response.model.:String = {model: String}", { model: opts.model });
qb.where("attributes.trigger.llm.total_cost.:Float64 IS NULL", {});
qb.where("kind = {kind: String}", { kind: "SPAN" });
qb.where("status = {status: String}", { status: "OK" });
qb.orderBy("start_time DESC");
qb.limit(limit);
const [err, rows] = await qb.execute();
if (err) {
throw err;
}
return rows ?? [];
}
function formatDateTime(date: Date): string {
return date.toISOString().replace("T", " ").replace("Z", "");
}
+794
View File
@@ -0,0 +1,794 @@
import { json } from "@remix-run/server-runtime";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import {
authIncludeBase,
authIncludeWithParent,
findEnvironmentByApiKey,
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
import { missingJwtLogContext } from "./safeRequestLogContext";
import {
type PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import {
type OrganizationAccessTokenAuthenticationResult,
authenticateApiRequestWithOrganizationAccessToken,
isOrganizationAccessToken,
} from "./organizationAccessToken.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
// One-time use token
otu: z.boolean().optional(),
realtime: z
.object({
skipColumns: z.array(z.string()).optional(),
})
.optional(),
});
// Re-export the slim shape defined in @trigger.dev/core. Single source of
// truth across the auth boundary (RBAC plugin contract → webapp handlers).
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
export type ApiAuthenticationResult =
| ApiAuthenticationResultSuccess
| ApiAuthenticationResultFailure;
export type ApiAuthenticationResultSuccess = {
ok: true;
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
oneTimeUse?: boolean;
realtime?: {
skipColumns?: string[];
};
// Present when the request used a public JWT minted from a PAT/UAT exchange
// that stamped an `act` delegation claim. `actor.sub` is the acting user id,
// used for attribution (e.g. who resolved an error). Absent for plain env
// API keys (no user) and JWTs minted without delegation.
actor?: {
sub: string;
};
};
export type ApiAuthenticationResultFailure = {
ok: false;
error: string;
};
/**
* @deprecated Use `authenticateApiRequestWithFailure` instead.
*/
export async function authenticateApiRequest(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResultSuccess | undefined> {
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
const authentication = await authenticateApiKey(apiKey, { ...options, branchName });
return authentication;
}
/**
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
export async function authenticateApiRequestWithFailure(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult> {
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return {
ok: false,
error: "Invalid API Key",
};
}
const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName });
return authentication;
}
/**
* @deprecated Use `authenticateApiKeyWithFailure` instead.
*/
export async function authenticateApiKey(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
): Promise<ApiAuthenticationResultSuccess | undefined> {
const result = getApiKeyResult(apiKey);
if (!result) {
return;
}
if (!options.allowPublicKey && result.type === "PUBLIC") {
return;
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return;
}
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults.ok) {
return;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
}
}
}
/**
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
async function authenticateApiKeyWithFailure(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
): Promise<ApiAuthenticationResult> {
const result = getApiKeyResult(apiKey);
if (!result) {
return {
ok: false,
error: "Invalid API Key",
};
}
if (!options.allowPublicKey && result.type === "PUBLIC") {
return {
ok: false,
error: "Public API keys are not allowed for this request",
};
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return {
ok: false,
error: "Public JWT API keys are not allowed for this request",
};
}
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
if (!environment) {
return {
ok: false,
error: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
if (!environment) {
return {
ok: false,
error: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults.ok) {
return validationResults;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
}
}
}
export async function authenticateAuthorizationHeader(
authorization: string,
{
allowPublicKey = false,
allowJWT = false,
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromHeader(authorization);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
}
function isPublicApiKey(key: string) {
return key.startsWith("pk_");
}
function isSecretApiKey(key: string) {
return key.startsWith("tr_");
}
/**
* Reads the branch off the `x-trigger-branch` header and sanitizes it.
* Every server-side reader should go through here so sanitization is applied uniformly.
* The dev `"default"` sentinel is intentionally NOT resolved here:
* that translation is environment type-dependent.
*/
export function branchNameFromRequest(request: Request): string | undefined {
return sanitizeBranchName(request.headers.get("x-trigger-branch")) ?? undefined;
}
function getApiKeyFromRequest(request: Request): {
apiKey: string | undefined;
branchName: string | undefined;
} {
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
const branchName = branchNameFromRequest(request);
return { apiKey, branchName };
}
function getApiKeyFromHeader(authorization?: string | null) {
if (typeof authorization !== "string" || !authorization) {
return;
}
const apiKey = authorization.replace(/^Bearer /, "");
return apiKey;
}
function getApiKeyResult(apiKey: string): {
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
} {
const type = isPublicApiKey(apiKey)
? "PUBLIC"
: isSecretApiKey(apiKey)
? "PRIVATE"
: isPublicJWT(apiKey)
? "PUBLIC_JWT"
: "PRIVATE"; // Fallback to private key
return { apiKey, type };
}
export type AuthenticationResult =
| {
type: "personalAccessToken";
result: PersonalAccessTokenAuthenticationResult;
}
| {
type: "organizationAccessToken";
result: OrganizationAccessTokenAuthenticationResult;
}
| {
type: "apiKey";
result: ApiAuthenticationResult;
};
type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey";
type AllowedAuthenticationMethods = Record<AuthenticationMethod, boolean> &
({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true });
const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: true,
};
type FilteredAuthenticationResult<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods,
> =
| (T["personalAccessToken"] extends true
? Extract<AuthenticationResult, { type: "personalAccessToken" }>
: never)
| (T["organizationAccessToken"] extends true
? Extract<AuthenticationResult, { type: "organizationAccessToken" }>
: never)
| (T["apiKey"] extends true ? Extract<AuthenticationResult, { type: "apiKey" }> : never);
/**
* Authenticates an incoming request by checking for various token types.
*
* Supports personal access tokens, organization access tokens, and API keys.
* Returns the appropriate authentication result based on the token type found.
*
* This method currently only allows private keys for the `apiKey` authentication method.
*
* @template T - The allowed authentication methods configuration type
* @param request - The incoming HTTP request containing authentication headers
* @param allowedAuthenticationMethods - Configuration object specifying which authentication methods are allowed.
* At least one method must be set to `true`. Defaults to allowing all methods.
* @returns Authentication result with only the enabled auth method types, or undefined if no valid token found
*
* @example
* ```typescript
* // Only allow personal access tokens
* const result = await authenticateRequest(request, {
* personalAccessToken: true,
* organizationAccessToken: false,
* apiKey: false,
* });
* // result type: { type: "personalAccessToken"; result: PersonalAccessTokenAuthenticationResult } | undefined
* ```
*/
export async function authenticateRequest<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods,
>(
request: Request,
allowedAuthenticationMethods?: T
): Promise<FilteredAuthenticationResult<T> | undefined> {
const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods;
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) {
const result = await authenticateApiRequestWithPersonalAccessToken(request);
if (!result) {
return;
}
return {
type: "personalAccessToken",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "personalAccessToken" }
> as FilteredAuthenticationResult<T>;
}
if (allowedMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) {
const result = await authenticateApiRequestWithOrganizationAccessToken(request);
if (!result) {
return;
}
return {
type: "organizationAccessToken",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "organizationAccessToken" }
> as FilteredAuthenticationResult<T>;
}
if (allowedMethods.apiKey) {
const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName });
if (!result) {
return;
}
return {
type: "apiKey",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "apiKey" }
> as FilteredAuthenticationResult<T>;
}
return;
}
export async function authenticatedEnvironmentForAuthentication(
auth: AuthenticationResult,
projectRef: string,
slug: string,
branch?: string
): Promise<AuthenticatedEnvironment> {
if (slug === "staging") {
slug = "stg";
}
// Normalize the requested branch once: sanitize it, then collapse the dev
// `"default"` sentinel to "no branch" so it resolves to the root dev env
// rather than a (non-existent) branch literally named "default".
// TODO this slug check is brittle
const sanitizedBranch = sanitizeBranchName(branch);
const resolvedBranch =
slug === "dev" && isDefaultDevBranch(sanitizedBranch) ? null : sanitizedBranch;
switch (auth.type) {
case "apiKey": {
if (!auth.result.ok) {
throw json({ error: auth.result.error }, { status: 401 });
}
if (auth.result.environment.project.externalRef !== projectRef) {
throw json(
{
error:
"Invalid project ref for this API key. Make sure you are using an API key associated with that project.",
},
{ status: 400 }
);
}
if (
auth.result.environment.slug !== slug &&
auth.result.environment.branchName !== resolvedBranch
) {
throw json(
{
error:
"Invalid environment slug for this API key. Make sure you are using an API key associated with that environment.",
},
{ status: 400 }
);
}
return auth.result.environment;
}
case "personalAccessToken": {
const user = await $replica.user.findUnique({
where: {
id: auth.result.userId,
},
});
if (!user) {
throw json({ error: "Invalid or missing personal access token" }, { status: 401 });
}
const project = await findProjectByRef(projectRef, user.id);
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
type: slug === "dev" ? "DEVELOPMENT" : "PREVIEW",
branchName: resolvedBranch,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
archivedAt: null,
},
include: authIncludeWithParent,
});
if (!environment) {
throw json({ error: "Branch not found" }, { status: 404 });
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a parent environment" }, { status: 400 });
}
// PREVIEW envs (and DEVELOPMENT branches) reuse the parent's apiKey for downstream auth flows
// (signed JWTs, internal-fetch helpers). Override before mapping so
// the slim shape carries the parent's key.
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
});
}
case "organizationAccessToken": {
const organization = await $replica.organization.findUnique({
where: {
id: auth.result.organizationId,
},
});
if (!organization) {
throw json({ error: "Invalid or missing organization access token" }, { status: 401 });
}
const project = await $replica.project.findFirst({
where: {
organizationId: organization.id,
externalRef: projectRef,
},
});
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
// No Development branches for OAT
type: "PREVIEW",
branchName: resolvedBranch,
archivedAt: null,
},
include: authIncludeWithParent,
});
if (!environment) {
throw json({ error: "Branch not found" }, { status: 404 });
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
}
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
});
}
default: {
auth satisfies never;
throw json({ error: "Invalid authentication result" }, { status: 401 });
}
}
}
const JWT_SECRET = new TextEncoder().encode(env.SESSION_SECRET);
const JWT_ALGORITHM = "HS256";
const DEFAULT_JWT_EXPIRATION_IN_MS = 1000 * 60 * 60; // 1 hour
export async function generateJWTTokenForEnvironment(
environment: RuntimeEnvironmentForEnvRepo,
payload: Record<string, string>
) {
const jwt = await new SignJWT({
environment_id: environment.id,
org_id: environment.organizationId,
project_id: environment.projectId,
...payload,
})
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setExpirationTime(calculateJWTExpiration())
.sign(JWT_SECRET);
return jwt;
}
export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
request: Request,
payloadSchema: T
): Promise<{ payload: z.infer<T>; jwt: string } | undefined> {
try {
const jwt = request.headers.get("x-trigger-jwt");
if (!jwt) {
// Log a safe breadcrumb, not the raw headers (which carry the
// caller's Authorization credential).
logger.debug("Missing JWT token in request", missingJwtLogContext(request));
return;
}
const { payload: rawPayload } = await jwtVerify(jwt, JWT_SECRET, {
issuer: "https://id.trigger.dev",
audience: "https://api.trigger.dev",
});
const payload = payloadSchema.safeParse(rawPayload);
if (!payload.success) {
logger.error("Failed to validate JWT", { payload: rawPayload, issues: payload.error.issues });
return;
}
const renewedJwt = await renewJWTToken(payload.data);
return {
payload: payload.data,
jwt: renewedJwt,
};
} catch (error) {
if (error instanceof errors.JWTExpired) {
// Now we need to try and renew the token using the API key auth
const authenticatedEnv = await authenticateApiRequest(request);
if (!authenticatedEnv) {
logger.error("Failed to renew JWT token, missing or invalid Authorization header", {
error: error.message,
});
return;
}
if (!authenticatedEnv.ok) {
logger.error("Failed to renew JWT token, invalid API key", {
error: error.message,
});
return;
}
const payload = payloadSchema.safeParse(error.payload);
if (!payload.success) {
logger.error("Failed to parse jwt payload after expired", {
payload: error.payload,
issues: payload.error.issues,
});
return;
}
const renewedJwt = await generateJWTTokenForEnvironment(authenticatedEnv.environment, {
...payload.data,
});
// The environment carries secret material; log only non-secret fields.
logger.debug("Renewed JWT token from Authorization header API Key", {
environment: safeEnvironmentLogFields(authenticatedEnv.environment),
payload: payload.data,
});
return {
payload: payload.data,
jwt: renewedJwt,
};
}
logger.error("Failed to validate JWT token", { error });
}
}
async function renewJWTToken(payload: Record<string, string>) {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setExpirationTime(calculateJWTExpiration())
.sign(JWT_SECRET);
return jwt;
}
function calculateJWTExpiration() {
if (env.PROD_USAGE_HEARTBEAT_INTERVAL_MS) {
return (
(Date.now() + Math.max(DEFAULT_JWT_EXPIRATION_IN_MS, env.PROD_USAGE_HEARTBEAT_INTERVAL_MS)) /
1000
);
}
return (Date.now() + DEFAULT_JWT_EXPIRATION_IN_MS) / 1000;
}
export async function getOneTimeUseToken(
auth: ApiAuthenticationResultSuccess
): Promise<string | undefined> {
if (auth.type !== "PUBLIC_JWT") {
return;
}
if (!auth.oneTimeUse) {
return;
}
// Hash the API key to make it unique
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth.apiKey));
return Buffer.from(hash).toString("hex");
}
@@ -0,0 +1,82 @@
import { env } from "~/env.server";
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import type { Duration } from "./rateLimiter.server";
export const apiRateLimiter = authorizationRateLimitMiddleware({
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
keyPrefix: "api",
defaultLimiter: {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
maxItems: 1000,
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
allowPublicKey: true,
allowJWT: true,
});
if (!authenticatedEnv || !authenticatedEnv.ok) {
return;
}
if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
"/api/v1/authorization-code",
"/api/v1/token",
"/api/v1/usage/ingest",
"/api/v1/plain/customer-cards",
/^\/api\/v1\/tasks\/[^/]+\/callback\/[^/]+$/, // /api/v1/tasks/$id/callback/$secret
/^\/api\/v1\/runs\/[^/]+\/tasks\/[^/]+\/callback\/[^/]+$/, // /api/v1/runs/$runId/tasks/$id/callback/$secret
/^\/api\/v1\/http-endpoints\/[^/]+\/env\/[^/]+\/[^/]+$/, // /api/v1/http-endpoints/$httpEndpointId/env/$envType/$shortcode
/^\/api\/v1\/sources\/http\/[^/]+$/, // /api/v1/sources/http/$id
/^\/api\/v1\/endpoints\/[^/]+\/[^/]+\/index\/[^/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
"/api/v1/timezones",
"/api/v1/usage/ingest",
"/api/v1/auth/jwt/claims",
/^\/api\/v1\/runs\/[^/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
/^\/api\/v1\/waitpoints\/tokens\/[^/]+\/callback\/[^/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
/^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/*
// Internal SDK plumbing — packets are presigned-URL handshakes for
// payload uploads (v2 PUT) and downloads (v1 GET), authenticated via
// run-scoped JWT, called once per task/turn boundary by the runtime.
// Same shape as `/api/v1/runs/$runFriendlyId/attempts` above; not a
// customer-facing surface so customer rate limits shouldn't apply.
/^\/api\/v1\/packets\//,
/^\/api\/v2\/packets\//,
/^\/api\/v1\/sessions\/[^/]+\/snapshot-url$/,
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
@@ -0,0 +1,112 @@
import { type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { nanoid } from "nanoid";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
export class ArchiveBranchService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
// The orgFilter approach is not ideal but we need to keep it this way for now because of how the service is used in routes and api endpoints.
// Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership.
// Ideally we would take care of both the authentication and authorization checks in the controllers and routes.
// That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler.
orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string },
{
environmentId,
}: {
environmentId: string;
}
) {
try {
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
where: {
id: environmentId,
organization:
orgFilter.type === "userMembership"
? {
members: {
some: {
userId: orgFilter.userId,
},
},
}
: { id: orgFilter.organizationId },
// Dev branches are per-org-member, so org membership alone isn't enough:
// only the owner may archive their own dev branch. Non-dev branches (e.g.
// preview) remain scoped by org membership only.
...(orgFilter.type === "userMembership"
? {
OR: [
{ type: { not: "DEVELOPMENT" as const } },
{ orgMember: { userId: orgFilter.userId } },
],
}
: {}),
},
include: {
organization: {
select: {
id: true,
slug: true,
maximumConcurrencyLimit: true,
},
},
project: {
select: {
id: true,
slug: true,
},
},
},
});
// A branch is defined by having a parent; any root (dev/preview parent,
// prod, staging) has none and can't be archived. For dev, that root is
// the default branch, so give the clearer message.
if (!environment.parentEnvironmentId) {
return {
success: false as const,
error:
environment.type === "DEVELOPMENT"
? "The default development branch cannot be archived."
: "This isn't a branch, and cannot be archived.",
};
}
// Branch archive is a SOFT update — do NOT hard-delete run-ops rows here (it would destroy a
// retained branch's history). Any env hard-delete/purge belongs on a dedicated purge path
// (owned by the cloud env-purge runbook), which has no site today.
const slug = `${environment.slug}-${nanoid(6)}`;
const shortcode = slug;
const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({
where: { id: environmentId },
data: { archivedAt: new Date(), slug, shortcode },
});
// archivedAt/slug/shortcode changed in the control-plane; drop any cached copy.
controlPlaneResolver.invalidateEnvironment(environmentId);
return {
success: true as const,
branch: updatedBranch,
organization: environment.organization,
project: environment.project,
};
} catch (e) {
logger.error("ArchiveBranchService error", { environmentId, error: e });
return {
success: false as const,
error: "Failed to archive branch",
};
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
// Syncs new orgs/users into Attio (workspaces/users objects) at signup, via the
// common worker so a slow Attio never blocks signup. Ongoing field updates are
// handled by the scheduled sync, not here. No-op without ATTIO_API_KEY.
const ATTIO_API = "https://api.attio.com/v2";
const IS_TEST = env.APP_ENV !== "production";
export const AttioWorkspaceSyncSchema = z.object({
orgId: z.string(),
title: z.string(),
slug: z.string(),
companySize: z.string().nullish(),
createdAt: z.coerce.date(),
adminUserId: z.string(),
});
export type AttioWorkspaceSync = z.infer<typeof AttioWorkspaceSyncSchema>;
export const AttioUserSyncSchema = z.object({
userId: z.string(),
email: z.string(),
referralSource: z.string().nullish(),
marketingEmails: z.boolean(),
createdAt: z.coerce.date(),
});
export type AttioUserSync = z.infer<typeof AttioUserSyncSchema>;
class AttioClient {
constructor(private readonly apiKey: string) {}
// Create-or-update by unique attribute; returns the record id. Throws on failure so the worker retries.
async #assert(
object: string,
matchingAttribute: string,
values: Record<string, unknown>
): Promise<string> {
const url = `${ATTIO_API}/objects/${object}/records?matching_attribute=${matchingAttribute}`;
const response = await fetch(url, {
method: "PUT",
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { values } }),
});
if (!response.ok) {
const body = await response.text();
logger.error("Attio assert failed", {
object,
matchingAttribute,
status: response.status,
body,
});
throw new Error(`Attio assert ${object} failed with status ${response.status}`);
}
const recordId = ((await response.json()) as any).data?.id?.record_id;
if (typeof recordId !== "string") {
throw new Error(`Attio assert ${object}: response missing data.id.record_id`);
}
return recordId;
}
async upsertWorkspace(payload: AttioWorkspaceSync, emailDomain?: string) {
// The creating user is an admin of the new org — set their role and link them to the workspace.
const adminRecordId = await this.#assert("users", "user_id", {
user_id: payload.adminUserId,
role: "Admin",
is_test: IS_TEST,
});
await this.#assert("workspaces", "workspace_id", {
workspace_id: payload.orgId,
name: payload.title,
org_slug: payload.slug,
company_size: payload.companySize ?? undefined,
email_domain: emailDomain,
signup_date: toDate(payload.createdAt),
plan: "Free",
account_status: "Active",
is_test: IS_TEST,
users: [{ target_object: "users", target_record_id: adminRecordId }],
});
}
async upsertUser(payload: AttioUserSync) {
await this.#assert("users", "user_id", {
user_id: payload.userId,
primary_email_address: payload.email,
marketing_opt_in: payload.marketingEmails,
referral_source: payload.referralSource ?? undefined,
signup_date: toDate(payload.createdAt),
is_test: IS_TEST,
});
}
}
// Attio `date` attributes want a bare YYYY-MM-DD value.
function toDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Domain from an email; the cloud-side matcher normalizes it further.
function domainFromEmail(email: string | undefined): string | undefined {
return email?.split("@")[1]?.toLowerCase().trim() || undefined;
}
export const attioClient = env.ATTIO_API_KEY ? new AttioClient(env.ATTIO_API_KEY) : null;
export async function enqueueAttioWorkspaceSync(payload: AttioWorkspaceSync) {
if (!attioClient) return;
try {
// Lazy import to avoid a circular dependency with commonWorker (which imports this module's schemas).
const { commonWorker } = await import("~/v3/commonWorker.server");
await commonWorker.enqueue({
id: `attio:workspace:${payload.orgId}`,
job: "attio.syncWorkspace",
payload,
});
} catch (error) {
logger.error("Failed to enqueue Attio workspace sync", { orgId: payload.orgId, error });
}
}
export async function enqueueAttioUserSync(payload: AttioUserSync) {
if (!attioClient) return;
try {
const { commonWorker } = await import("~/v3/commonWorker.server");
await commonWorker.enqueue({
id: `attio:user:${payload.userId}`,
job: "attio.syncUser",
payload,
});
} catch (error) {
logger.error("Failed to enqueue Attio user sync", { userId: payload.userId, error });
}
}
export async function runAttioWorkspaceSync(payload: AttioWorkspaceSync) {
if (!attioClient) return;
const admin = await prisma.user.findFirst({
where: { id: payload.adminUserId },
select: { email: true },
});
await attioClient.upsertWorkspace(payload, domainFromEmail(admin?.email));
}
export async function runAttioUserSync(payload: AttioUserSync) {
if (!attioClient) return;
await attioClient.upsertUser(payload);
}
+33
View File
@@ -0,0 +1,33 @@
import { Authenticator } from "remix-auth";
import type { AuthUser } from "./authUser";
import { addEmailLinkStrategy } from "./emailAuth.server";
import { addGitHubStrategy } from "./gitHubAuth.server";
import { addGoogleStrategy } from "./googleAuth.server";
import { sessionStorage } from "./sessionStorage.server";
import { addSsoStrategy } from "./ssoAuth.server";
import { env } from "~/env.server";
// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session
const authenticator = new Authenticator<AuthUser>(sessionStorage);
const isGithubAuthSupported =
typeof env.AUTH_GITHUB_CLIENT_ID === "string" &&
typeof env.AUTH_GITHUB_CLIENT_SECRET === "string";
const isGoogleAuthSupported =
typeof env.AUTH_GOOGLE_CLIENT_ID === "string" &&
typeof env.AUTH_GOOGLE_CLIENT_SECRET === "string";
if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) {
addGitHubStrategy(authenticator, env.AUTH_GITHUB_CLIENT_ID, env.AUTH_GITHUB_CLIENT_SECRET);
}
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
addGoogleStrategy(authenticator, env.AUTH_GOOGLE_CLIENT_ID, env.AUTH_GOOGLE_CLIENT_SECRET);
}
addEmailLinkStrategy(authenticator);
addSsoStrategy(authenticator);
export { authenticator, isGithubAuthSupported, isGoogleAuthSupported };
+11
View File
@@ -0,0 +1,11 @@
export type AuthUser = {
userId: string;
// Present only when the session was established via SSO. Carries the
// minimum the periodic re-validation hook needs to ask the IdP whether
// the session is still valid. Signed into the session cookie, so it's
// tamper-proof. Absent ⇒ non-SSO session ⇒ never revalidated.
sso?: {
idpOrgId: string;
connectionId: string;
};
};
@@ -0,0 +1,307 @@
import type { Cache as UnkeyCache } from "@unkey/cache";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { Ratelimit } from "@upstash/ratelimit";
import type { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { createHash } from "node:crypto";
import { z } from "zod";
import { env } from "~/env.server";
import type { RedisWithClusterOptions } from "~/redis.server";
import { logger } from "./logger.server";
import type { Duration, Limiter } from "./rateLimiter.server";
import { createRedisRateLimitClient, RateLimiter } from "./rateLimiter.server";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
const DurationSchema = z.custom<Duration>((value) => {
if (typeof value !== "string") {
throw new Error("Duration must be a string");
}
return value as Duration;
});
export const RateLimitFixedWindowConfig = z.object({
type: z.literal("fixedWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitFixedWindowConfig = z.infer<typeof RateLimitFixedWindowConfig>;
export const RateLimitSlidingWindowConfig = z.object({
type: z.literal("slidingWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitSlidingWindowConfig = z.infer<typeof RateLimitSlidingWindowConfig>;
export const RateLimitTokenBucketConfig = z.object({
type: z.literal("tokenBucket"),
refillRate: z.number(),
interval: DurationSchema,
maxTokens: z.number(),
});
export type RateLimitTokenBucketConfig = z.infer<typeof RateLimitTokenBucketConfig>;
export const RateLimiterConfig = z.discriminatedUnion("type", [
RateLimitFixedWindowConfig,
RateLimitSlidingWindowConfig,
RateLimitTokenBucketConfig,
]);
export type RateLimiterConfig = z.infer<typeof RateLimiterConfig>;
type LimitConfigOverrideFunction = (authorizationValue: string) => Promise<unknown>;
type Options = {
redis?: RedisWithClusterOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
defaultLimiter: RateLimiterConfig;
limiterConfigOverride?: LimitConfigOverrideFunction;
limiterCache?: {
fresh: number;
stale: number;
maxItems: number;
};
log?: {
requests?: boolean;
rejections?: boolean;
limiter?: boolean;
};
};
async function resolveLimitConfig(
authorizationValue: string,
hashedAuthorizationValue: string,
defaultLimiter: RateLimiterConfig,
cache: UnkeyCache<{ limiter: RateLimiterConfig }>,
logsEnabled: boolean,
limiterConfigOverride?: LimitConfigOverrideFunction
): Promise<RateLimiterConfig> {
if (!limiterConfigOverride) {
return defaultLimiter;
}
if (logsEnabled) {
logger.info("RateLimiter: checking for override", {
authorizationValue: hashedAuthorizationValue,
defaultLimiter,
});
}
const cacheResult = await cache.limiter.swr(hashedAuthorizationValue, async (key) => {
const override = await limiterConfigOverride(authorizationValue);
if (!override) {
if (logsEnabled) {
logger.info("RateLimiter: no override found", {
authorizationValue,
defaultLimiter,
});
}
return defaultLimiter;
}
const parsedOverride = RateLimiterConfig.safeParse(override);
if (!parsedOverride.success) {
logger.error("Error parsing rate limiter override", {
override,
errors: parsedOverride.error.errors,
});
return defaultLimiter;
}
if (logsEnabled && parsedOverride.data) {
logger.info("RateLimiter: override found", {
authorizationValue,
defaultLimiter,
override: parsedOverride.data,
});
}
return parsedOverride.data;
});
return cacheResult.val ?? defaultLimiter;
}
/**
* Creates a Ratelimit limiter from a RateLimiterConfig.
* This function is shared across the codebase to ensure consistent limiter creation.
*/
export function createLimiterFromConfig(config: RateLimiterConfig): Limiter {
return config.type === "fixedWindow"
? Ratelimit.fixedWindow(config.tokens, config.window)
: config.type === "tokenBucket"
? Ratelimit.tokenBucket(config.refillRate, config.interval, config.maxTokens)
: Ratelimit.slidingWindow(config.tokens, config.window);
}
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
defaultLimiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
limiterCache,
limiterConfigOverride,
}: Options) {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(limiterCache?.maxItems ?? 1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
...redis,
},
});
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
const cache = createCache({
limiter: new Namespace<RateLimiterConfig>(ctx, {
stores: [memory, redisCacheStore],
fresh: limiterCache?.fresh ?? 30_000,
stale: limiterCache?.stale ?? 60_000,
}),
});
const redisClient = createRedisRateLimitClient(
redis ?? {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
}
);
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const limiterConfig = await resolveLimitConfig(
authorizationValue,
hashedAuthorizationValue,
defaultLimiter,
cache,
typeof log.limiter === "boolean" ? log.limiter : false,
limiterConfigOverride
);
const limiter = createLimiterFromConfig(limiterConfig);
const rateLimiter = new RateLimiter({
redisClient,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
@@ -0,0 +1,86 @@
import type { RedisOptions } from "ioredis";
import Redis from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import type { PrismaClientOrTransaction, PrismaTransactionOptions } from "~/db.server";
import { Prisma, prisma } from "~/db.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
export type AutoIncrementCounterOptions = {
redis: RedisOptions;
};
export class AutoIncrementCounter {
private _redis: Redis;
constructor(private options: AutoIncrementCounterOptions) {
this._redis = new Redis({ reconnectOnError: defaultReconnectOnError, ...options.redis });
}
async incrementInTransaction<T>(
key: string,
callback: (num: number, tx: PrismaClientOrTransaction) => Promise<T>,
backfiller?: (key: string, db: PrismaClientOrTransaction) => Promise<number | undefined>,
client: PrismaClientOrTransaction = prisma,
transactionOptions?: PrismaTransactionOptions
): Promise<T | undefined> {
let performedIncrement = false;
let performedBackfill = false;
try {
let newNumber = await this.#increment(key);
performedIncrement = true;
if (newNumber === 1 && backfiller) {
const backfilledNumber = await backfiller(key, client);
if (backfilledNumber && backfilledNumber > 1) {
newNumber = backfilledNumber + 1;
await this._redis.set(key, newNumber);
performedBackfill = true;
}
}
return await callback(newNumber, client);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError ||
e instanceof Prisma.PrismaClientUnknownRequestError ||
e instanceof Prisma.PrismaClientValidationError
) {
if (performedIncrement && !performedBackfill) {
await this._redis.decr(key);
}
}
throw e;
}
}
async #increment(key: string): Promise<number> {
return await this._redis.incr(key);
}
}
export const autoIncrementCounter = singleton("auto-increment-counter", getAutoIncrementCounter);
function getAutoIncrementCounter() {
if (!env.REDIS_HOST || !env.REDIS_PORT) {
throw new Error(
"Could not initialize auto-increment counter because process.env.REDIS_HOST and process.env.REDIS_PORT are required to be set. "
);
}
return new AutoIncrementCounter({
redis: {
keyPrefix: "auto-counter:",
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
});
}
@@ -0,0 +1,169 @@
import { wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { z } from "zod";
import { env } from "~/env.server";
const StatusPageSchema = z.object({
data: z.object({
id: z.string(),
type: z.string(),
attributes: z.object({
aggregate_state: z.enum(["operational", "degraded", "downtime"]),
}),
}),
});
const StatusReportsSchema = z.object({
data: z.array(
z.object({
id: z.string(),
type: z.literal("status_report"),
attributes: z.object({
title: z.string().nullable(),
starts_at: z.string().nullable(),
ends_at: z.string().nullable(),
aggregate_state: z.string().nullable(),
}),
})
),
pagination: z.object({
first: z.string().nullable(),
last: z.string().nullable(),
prev: z.string().nullable(),
next: z.string().nullable(),
}),
});
export type AggregateState = "operational" | "degraded" | "downtime";
export type IncidentStatus = {
status: AggregateState;
title: string | null;
};
type CachedResult = { success: true; data: IncidentStatus } | { success: false; error: unknown };
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(100);
const cache = createCache({
query: new Namespace<CachedResult>(ctx, {
stores: [memory],
fresh: 15_000,
stale: 30_000,
}),
});
export class BetterStackClient {
private readonly baseUrl = "https://uptime.betterstack.com/api/v2";
async getIncidentStatus(): Promise<CachedResult> {
const apiKey = env.BETTERSTACK_API_KEY;
const statusPageId = env.BETTERSTACK_STATUS_PAGE_ID;
if (!apiKey || !statusPageId) {
return { success: false, error: "Missing BetterStack configuration" };
}
const cachedResult = await cache.query.swr("betterstack-incident-status", () =>
this.fetchIncidentStatus(apiKey, statusPageId)
);
if (cachedResult.err || !cachedResult.val) {
return { success: false, error: cachedResult.err ?? "No result from cache" };
}
return cachedResult.val;
}
private async fetchIncidentStatus(apiKey: string, statusPageId: string): Promise<CachedResult> {
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
const retryConfig = {
retry: { maxAttempts: 3, minTimeoutInMs: 1000, maxTimeoutInMs: 5000 },
};
try {
// Fetch the status page to get aggregate state
const statusPageResult = await wrapZodFetch(
StatusPageSchema,
`${this.baseUrl}/status-pages/${statusPageId}`,
{ headers },
retryConfig
);
if (!statusPageResult.success) {
return { success: false, error: statusPageResult.error };
}
const status = statusPageResult.data.data.attributes.aggregate_state;
// If operational, no need to fetch reports
if (status === "operational") {
return { success: true, data: { status, title: null } };
}
// Fetch status reports to get the incident title
const title = await this.fetchActiveReportTitle(apiKey, statusPageId, headers, retryConfig);
return { success: true, data: { status, title } };
} catch (error) {
console.error("Failed to fetch incident status from BetterStack:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private async fetchActiveReportTitle(
apiKey: string,
statusPageId: string,
headers: Record<string, string>,
retryConfig: { retry: { maxAttempts: number; minTimeoutInMs: number; maxTimeoutInMs: number } }
): Promise<string | null> {
const reportsUrl = `${this.baseUrl}/status-pages/${statusPageId}/status-reports`;
let reportsResult = await wrapZodFetch(
StatusReportsSchema,
reportsUrl,
{ headers },
retryConfig
);
if (!reportsResult.success) {
return null;
}
// Fetch last page if there are multiple pages (most recent reports are at the end)
const { first, last } = reportsResult.data.pagination;
if (last && last !== first) {
const lastPageResult = await wrapZodFetch(
StatusReportsSchema,
last,
{ headers },
retryConfig
);
if (lastPageResult.success) {
reportsResult = lastPageResult;
}
}
// Find active reports (not resolved, not ended)
const activeReports = reportsResult.data.data.filter(
(report) =>
report.attributes.aggregate_state !== "resolved" && report.attributes.ends_at === null
);
if (activeReports.length === 0) {
return null;
}
// Return the title from the most recent active report
const mostRecent = activeReports[activeReports.length - 1];
return mostRecent.attributes.title;
}
}
@@ -0,0 +1,181 @@
import type { BillingClient } from "@trigger.dev/platform";
import { z } from "zod";
/**
* Billing limit API schemas for the billing platform service.
*
* These mirror the planned @trigger.dev/platform types and are used via
* BillingClient.fetch until the platform package is published with native
* BillingClient methods.
*/
export const BillingLimitStateSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("ok"),
}),
z.object({
status: z.literal("grace"),
hitAt: z.string().datetime({ offset: true }),
graceEndsAt: z.string().datetime({ offset: true }),
}),
z.object({
status: z.literal("rejected"),
hitAt: z.string().datetime({ offset: true }),
graceEndsAt: z.string().datetime({ offset: true }),
}),
]);
export type BillingLimitState = z.infer<typeof BillingLimitStateSchema>;
export const BillingLimitConfigSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("none"),
}),
z.object({
mode: z.literal("plan"),
}),
z.object({
mode: z.literal("custom"),
amountCents: z.number().int().positive(),
}),
]);
export type BillingLimitConfig = z.infer<typeof BillingLimitConfigSchema>;
export const BillingLimitUnconfiguredSchema = z.object({
isConfigured: z.literal(false),
gracePeriodMs: z.number().int().nonnegative(),
});
const billingLimitConfiguredFields = {
isConfigured: z.literal(true),
cancelInProgressRuns: z.boolean(),
limitState: BillingLimitStateSchema,
effectiveAmountCents: z.number().int().nonnegative().nullable(),
gracePeriodMs: z.number().int().nonnegative(),
};
export const BillingLimitConfiguredNoneSchema = z.object({
...billingLimitConfiguredFields,
mode: z.literal("none"),
});
export const BillingLimitConfiguredPlanSchema = z.object({
...billingLimitConfiguredFields,
mode: z.literal("plan"),
});
export const BillingLimitConfiguredCustomSchema = z.object({
...billingLimitConfiguredFields,
mode: z.literal("custom"),
amountCents: z.number().int().positive(),
});
export const BillingLimitConfiguredSchema = z.discriminatedUnion("mode", [
BillingLimitConfiguredNoneSchema,
BillingLimitConfiguredPlanSchema,
BillingLimitConfiguredCustomSchema,
]);
export const BillingLimitResultSchema = z.union([
BillingLimitUnconfiguredSchema,
BillingLimitConfiguredNoneSchema,
BillingLimitConfiguredPlanSchema,
BillingLimitConfiguredCustomSchema,
]);
export type BillingLimitResult = z.infer<typeof BillingLimitResultSchema>;
export const UpdateBillingLimitRequestSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("none"),
cancelInProgressRuns: z.boolean(),
}),
z.object({
mode: z.literal("plan"),
cancelInProgressRuns: z.boolean(),
}),
z.object({
mode: z.literal("custom"),
amountCents: z.number().int().positive(),
cancelInProgressRuns: z.boolean(),
}),
]);
export type UpdateBillingLimitRequest = z.infer<typeof UpdateBillingLimitRequestSchema>;
export const ResolveBillingLimitRequestSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("increase"),
newAmountCents: z.number().int().positive(),
resumeMode: z.enum(["queue", "new_only"]),
}),
z.object({
action: z.literal("remove"),
resumeMode: z.enum(["queue", "new_only"]),
}),
]);
export type ResolveBillingLimitRequest = z.infer<typeof ResolveBillingLimitRequestSchema>;
export const BillingLimitActiveOrgSchema = z.object({
orgId: z.string(),
limitState: z.enum(["grace", "rejected"]),
});
export const BillingLimitsActiveResultSchema = z.object({
orgs: z.array(BillingLimitActiveOrgSchema),
});
export type BillingLimitsActiveResult = z.infer<typeof BillingLimitsActiveResultSchema>;
export const BillingLimitPendingResolveOrgSchema = z.object({
organizationId: z.string(),
resumeMode: z.enum(["queue", "new_only"]),
resolvedAt: z.string().datetime({ offset: true }),
});
export const BillingLimitsPendingResolvesResultSchema = z.object({
orgs: z.array(BillingLimitPendingResolveOrgSchema),
});
export type BillingLimitsPendingResolvesResult = z.infer<
typeof BillingLimitsPendingResolvesResultSchema
>;
export const BillingLimitHitWebhookBodySchema = z.object({
hitAt: z.string().datetime({ offset: true }),
cancelInProgressRuns: z.boolean(),
limitState: z.literal("grace"),
});
export type BillingLimitHitWebhookBody = z.infer<typeof BillingLimitHitWebhookBodySchema>;
/** Entitlement response — mirrors ReportUsageResult with billing limit fields until platform ships native types. */
export const EntitlementResultSchema = z.object({
hasAccess: z.boolean(),
balance: z.number().optional(),
usage: z.number().optional(),
overage: z.number().optional(),
plan: z
.object({
type: z.string(),
code: z.string(),
isPaying: z.boolean(),
})
.optional(),
limitState: z.literal("grace").optional(),
reason: z.enum(["free_tier_exceeded", "billing_limit"]).optional(),
});
export type EntitlementResult = z.infer<typeof EntitlementResultSchema>;
export type BillingLimitPageData = BillingLimitResult & {
queuedRunCount: number;
currentSpendCents: number;
};
/** Bridge webapp Zod schemas to BillingClient.fetch (separate Zod type instances). */
export function asPlatformSchema(schema: z.ZodTypeAny) {
return schema as unknown as Parameters<BillingClient["fetch"]>[1];
}
@@ -0,0 +1,599 @@
import { ClickHouse } from "@internal/clickhouse";
import { createHash } from "crypto";
import { ClickhouseEventRepository } from "~/v3/eventRepository/clickhouseEventRepository.server";
import { env } from "~/env.server";
import { clampToEmergencySpanCap } from "~/v3/eventRepository/emergencySpanCap.server";
import { singleton } from "~/utils/singleton";
import type { OrganizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistry.server";
import { type IEventRepository } from "~/v3/eventRepository/eventRepository.types";
// ---------------------------------------------------------------------------
// Default clients (singleton per process)
// ---------------------------------------------------------------------------
const defaultClickhouseClient = singleton("clickhouseClient", initializeClickhouseClient);
function initializeClickhouseClient() {
const url = new URL(env.CLICKHOUSE_URL);
url.searchParams.delete("secure");
console.log(`🗃️ Clickhouse service enabled to host ${url.host}`);
return new ClickHouse({
url: url.toString(),
name: "clickhouse-instance",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
const defaultLogsClickhouseClient = singleton(
"logsClickhouseClient",
initializeLogsClickhouseClient
);
function getLogsListClickhouseSettings() {
return {
max_memory_usage: env.CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE.toString(),
max_bytes_before_external_sort:
env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(),
max_threads: env.CLICKHOUSE_LOGS_LIST_MAX_THREADS,
// Cap per-part read buffers so read-in-order memory stays bounded. These exist everywhere.
prefetch_buffer_size: env.CLICKHOUSE_LOGS_LIST_PREFETCH_BUFFER_SIZE.toString(),
max_read_buffer_size: env.CLICKHOUSE_LOGS_LIST_MAX_READ_BUFFER_SIZE.toString(),
// Object-storage only and newer than the buffers above, so only send it when configured to
// avoid UNKNOWN_SETTING failures against older self-hosted ClickHouse that lack it.
...(env.CLICKHOUSE_LOGS_LIST_FILESYSTEM_CACHE_PREFER_BIGGER_BUFFER_SIZE !== undefined && {
filesystem_cache_prefer_bigger_buffer_size:
env.CLICKHOUSE_LOGS_LIST_FILESYSTEM_CACHE_PREFER_BIGGER_BUFFER_SIZE,
}),
...(env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ && {
max_rows_to_read: env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ.toString(),
}),
...(env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME && {
max_execution_time: env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME,
}),
};
}
function initializeLogsClickhouseClient() {
if (!env.LOGS_CLICKHOUSE_URL) {
throw new Error("LOGS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.LOGS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "logs-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: getLogsListClickhouseSettings(),
});
}
const defaultAdminClickhouseClient = singleton(
"adminClickhouseClient",
initializeAdminClickhouseClient
);
function initializeAdminClickhouseClient() {
if (!env.ADMIN_CLICKHOUSE_URL) {
throw new Error("ADMIN_CLICKHOUSE_URL is not set");
}
const url = new URL(env.ADMIN_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "admin-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
const defaultQueryClickhouseClient = singleton(
"queryClickhouseClient",
initializeQueryClickhouseClient
);
function initializeQueryClickhouseClient() {
if (!env.QUERY_CLICKHOUSE_URL) {
throw new Error("QUERY_CLICKHOUSE_URL is not set");
}
const url = new URL(env.QUERY_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "query-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** TaskRun replication to ClickHouse (`RUN_REPLICATION_CLICKHOUSE_URL`); not exported. */
const defaultRunsReplicationClickhouseClient = singleton(
"runsReplicationClickhouseClient",
initializeRunsReplicationClickhouseClient
);
function initializeRunsReplicationClickhouseClient(): ClickHouse {
if (!env.RUN_REPLICATION_CLICKHOUSE_URL) {
// Runs replication worker gates on this URL; factory may still resolve "replication" for tests.
return defaultClickhouseClient;
}
const url = new URL(env.RUN_REPLICATION_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "runs-replication",
keepAlive: {
enabled: env.RUN_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS,
});
}
/** Session replication to ClickHouse (`SESSION_REPLICATION_CLICKHOUSE_URL`); not exported. */
const defaultSessionsReplicationClickhouseClient = singleton(
"sessionsReplicationClickhouseClient",
initializeSessionsReplicationClickhouseClient
);
function initializeSessionsReplicationClickhouseClient(): ClickHouse {
if (!env.SESSION_REPLICATION_CLICKHOUSE_URL) {
// Sessions replication worker gates on this URL; factory may still resolve "sessions_replication" for tests.
return defaultClickhouseClient;
}
const url = new URL(env.SESSION_REPLICATION_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "sessions-replication",
keepAlive: {
enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS,
});
}
/** Run-engine PendingVersionSystem lookup (`RUN_ENGINE_CLICKHOUSE_URL`);
* falls back to the default client if unset. */
const defaultRunEngineClickhouseClient = singleton(
"runEngineClickhouseClient",
initializeRunEngineClickhouseClient
);
function initializeRunEngineClickhouseClient(): ClickHouse {
if (!env.RUN_ENGINE_CLICKHOUSE_URL) {
return defaultClickhouseClient;
}
const url = new URL(env.RUN_ENGINE_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "run-engine-clickhouse",
keepAlive: {
enabled: env.RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_ENGINE_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.RUN_ENGINE_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** Realtime runs feed tag/batch id resolution (`REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL`);
* falls back to the default client if unset. */
const defaultRealtimeClickhouseClient = singleton(
"realtimeClickhouseClient",
initializeRealtimeClickhouseClient
);
function initializeRealtimeClickhouseClient(): ClickHouse {
if (!env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL) {
return defaultClickhouseClient;
}
const url = new URL(env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "realtime-runs-clickhouse",
keepAlive: {
enabled: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** Task events (`EVENTS_CLICKHOUSE_URL`); not exported — accessed via factory. */
const defaultEventsClickhouseClient = singleton(
"eventsClickhouseClient",
initializeEventsClickhouseClient
);
function initializeEventsClickhouseClient(): ClickHouse {
if (!env.EVENTS_CLICKHOUSE_URL) {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "task-events",
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.EVENTS_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function hashHostname(url: string): string {
const parsed = new URL(url);
return createHash("sha256").update(parsed.hostname).digest("hex");
}
export type ClientType =
| "standard"
| "events"
| "replication"
| "sessions_replication"
| "logs"
| "query"
| "admin"
| "engine"
| "realtime";
function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHouse {
const parsed = new URL(url);
parsed.searchParams.delete("secure");
const name = `org-clickhouse-${clientType}`;
switch (clientType) {
case "events":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.EVENTS_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
case "replication":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.RUN_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.RUN_REPLICATION_MAX_OPEN_CONNECTIONS,
});
case "sessions_replication":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.SESSION_REPLICATION_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.SESSION_REPLICATION_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.SESSION_REPLICATION_CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.SESSION_REPLICATION_MAX_OPEN_CONNECTIONS,
});
case "logs":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: getLogsListClickhouseSettings(),
});
case "engine":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUN_ENGINE_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.RUN_ENGINE_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
case "realtime":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
case "standard":
case "query":
case "admin":
return new ClickHouse({
url: parsed.toString(),
name,
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: { request: true },
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
}
// ---------------------------------------------------------------------------
// Factory class (injectable for testing)
// ---------------------------------------------------------------------------
export class ClickhouseFactory {
/** ClickHouse clients keyed by hostname hash + clientType. */
private readonly _clientCache = new Map<string, ClickHouse>();
/** Event repositories keyed by hostname hash (stateful, must be reused). */
private readonly _eventRepositoryCache = new Map<string, ClickhouseEventRepository>();
constructor(private readonly _registry: OrganizationDataStoresRegistry) {}
async isReady(): Promise<boolean> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return true;
}
async getClickhouseForOrganization(
organizationId: string,
clientType: ClientType
): Promise<ClickHouse> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return this.getClickhouseForOrganizationSync(organizationId, clientType);
}
getClickhouseForOrganizationSync(organizationId: string, clientType: ClientType): ClickHouse {
const dataStore = this._registry.get(organizationId, "CLICKHOUSE");
if (!dataStore) {
switch (clientType) {
case "standard":
return defaultClickhouseClient;
case "events":
return defaultEventsClickhouseClient;
case "replication":
return defaultRunsReplicationClickhouseClient;
case "sessions_replication":
return defaultSessionsReplicationClickhouseClient;
case "logs":
return defaultLogsClickhouseClient;
case "query":
return defaultQueryClickhouseClient;
case "admin":
return defaultAdminClickhouseClient;
case "engine":
return defaultRunEngineClickhouseClient;
case "realtime":
return defaultRealtimeClickhouseClient;
}
}
const hostnameHash = hashHostname(dataStore.url);
const cacheKey = `${hostnameHash}:${clientType}`;
let client = this._clientCache.get(cacheKey);
if (!client) {
client = buildOrgClickhouseClient(dataStore.url, clientType);
this._clientCache.set(cacheKey, client);
}
return client;
}
async getEventRepositoryForOrganization(
store: string,
organizationId: string
): Promise<{ key: string; repository: IEventRepository }> {
if (!this._registry.isLoaded) {
await this._registry.isReady;
}
return this.getEventRepositoryForOrganizationSync(store, organizationId);
}
getEventRepositoryForOrganizationSync(
store: string,
organizationId: string
): { key: string; repository: IEventRepository } {
const dataStore = this._registry.get(organizationId, "CLICKHOUSE");
if (!dataStore) {
const defaultKey = `default:events:${store}`;
let defaultRepo = this._eventRepositoryCache.get(defaultKey);
if (!defaultRepo) {
const eventsClickhouse = getEventsClickhouseClient();
defaultRepo = buildEventRepository(store, eventsClickhouse);
this._eventRepositoryCache.set(defaultKey, defaultRepo);
}
return { key: defaultKey, repository: defaultRepo };
}
const hostnameHash = hashHostname(dataStore.url);
const cacheKey = `${hostnameHash}:events:${store}`;
let repository = this._eventRepositoryCache.get(cacheKey);
if (!repository) {
const client = this.getClickhouseForOrganizationSync(organizationId, "events");
repository = buildEventRepository(store, client);
this._eventRepositoryCache.set(cacheKey, repository);
}
return { key: cacheKey, repository: repository };
}
}
/**
* Get admin ClickHouse client for cross-organization queries.
* Only use for admin tools and analytics that need to query across all orgs.
*/
export function getAdminClickhouse(): ClickHouse {
return defaultAdminClickhouseClient;
}
export function getDefaultClickhouseClient(): ClickHouse {
return defaultClickhouseClient;
}
export function getDefaultLogsClickhouseClient(): ClickHouse {
return defaultLogsClickhouseClient;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
function getEventsClickhouseClient(): ClickHouse {
return defaultEventsClickhouseClient;
}
function buildEventRepository(store: string, clickhouse: ClickHouse): ClickhouseEventRepository {
switch (store) {
case "clickhouse": {
return new ClickhouseEventRepository({
clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: clampToEmergencySpanCap(
env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT
),
maximumTraceDetailedSummaryViewCount: clampToEmergencySpanCap(
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT
),
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
otlpMetricsBatchSize: env.METRICS_CLICKHOUSE_BATCH_SIZE,
otlpMetricsFlushInterval: env.METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS,
otlpMetricsMaxConcurrency: env.METRICS_CLICKHOUSE_MAX_CONCURRENCY,
version: "v1",
});
}
case "clickhouse_v2": {
return new ClickhouseEventRepository({
clickhouse: clickhouse,
batchSize: env.EVENTS_CLICKHOUSE_BATCH_SIZE,
flushInterval: env.EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS,
maximumTraceSummaryViewCount: clampToEmergencySpanCap(
env.EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT
),
maximumTraceDetailedSummaryViewCount: clampToEmergencySpanCap(
env.EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT
),
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
insertStrategy: env.EVENTS_CLICKHOUSE_INSERT_STRATEGY,
waitForAsyncInsert: env.EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT === "1",
asyncInsertMaxDataSize: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE,
asyncInsertBusyTimeoutMs: env.EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS,
startTimeMaxAgeMs: env.EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS,
llmMetricsBatchSize: env.LLM_METRICS_BATCH_SIZE,
llmMetricsFlushInterval: env.LLM_METRICS_FLUSH_INTERVAL_MS,
llmMetricsMaxBatchSize: env.LLM_METRICS_MAX_BATCH_SIZE,
llmMetricsMaxConcurrency: env.LLM_METRICS_MAX_CONCURRENCY,
otlpMetricsBatchSize: env.METRICS_CLICKHOUSE_BATCH_SIZE,
otlpMetricsFlushInterval: env.METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS,
otlpMetricsMaxConcurrency: env.METRICS_CLICKHOUSE_MAX_CONCURRENCY,
version: "v2",
});
}
default: {
throw new Error(`Unknown ClickHouse event repository store: ${store}`);
}
}
}
@@ -0,0 +1,13 @@
import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server";
import { singleton } from "~/utils/singleton";
import { ClickhouseFactory } from "./clickhouseFactory.server";
/**
* Production singleton wired to the global organization data-stores registry.
* Import this only from app/runtime code — not from tests that construct a
* {@link ClickhouseFactory} with a stub registry (see `clickhouseFactory.server.ts`).
*/
export const clickhouseFactory = singleton(
"clickhouseFactory",
() => new ClickhouseFactory(organizationDataStoresRegistry)
);
@@ -0,0 +1,11 @@
import { z } from "zod";
export const ClickhouseConnectionSchema = z.object({
url: z.string().url(),
});
export type ClickhouseConnection = z.infer<typeof ClickhouseConnectionSchema>;
export function getClickhouseSecretKey(orgId: string, clientType: string): string {
return `org:${orgId}:clickhouse:${clientType}`;
}
@@ -0,0 +1,229 @@
import { signUserActorToken } from "@trigger.dev/rbac";
import { TriggerClient } from "@trigger.dev/sdk";
import { chat } from "@trigger.dev/sdk/ai";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { runStore } from "~/v3/runStore.server";
import { githubApp } from "./gitHub.server";
import { logger } from "./logger.server";
const TASK_ID = "dashboard-agent";
// Read-only cap on the agent's delegated user-actor token. `read:apiKeys` is
// what lets it exchange the token for an env JWT (the gate on the exchange
// route); the rest scope the actual reads. No write/admin scopes, so even a
// leaked token can't mutate anything.
const DASHBOARD_AGENT_UAT_CAP = [
"read:apiKeys",
"read:runs",
"read:deployments",
"read:environments",
"read:errors",
"read:query",
];
// Minted fresh on every turn (the `in` proxy injects it), so the lifetime only
// has to cover a single turn's tool calls. Short by design — a stale token in
// the agent's run payload expires quickly.
const DASHBOARD_AGENT_UAT_TTL_SECONDS = 10 * 60;
// The Trigger instance this webapp runs against — the same origin the agent
// task calls back to (as the user) for its read tools.
export function dashboardAgentApiOrigin(): string {
return env.API_ORIGIN ?? env.APP_ORIGIN;
}
// Mint a short-lived, read-only delegated token for the signed-in user. Self
// service from the dashboard session (never a PAT), so a user can only ever
// mint a token for themselves. The `in` proxy injects this into the turn's
// metadata so the token reaches the agent without ever touching the browser.
export function mintDashboardAgentUserActorToken(userId: string): Promise<string> {
return signUserActorToken(env.SESSION_SECRET, {
userId,
client: "dashboard-agent",
cap: DASHBOARD_AGENT_UAT_CAP,
expirationTime: Math.floor(Date.now() / 1000) + DASHBOARD_AGENT_UAT_TTL_SECONDS,
});
}
// The session is created in whatever env DASHBOARD_AGENT_SECRET_KEY belongs to.
// baseURL is the Trigger instance this webapp runs against (its own API origin).
function dashboardAgentConfig() {
const accessToken = env.DASHBOARD_AGENT_SECRET_KEY;
if (!accessToken) return null;
return { baseURL: dashboardAgentApiOrigin(), accessToken };
}
export function isDashboardAgentConfigured(): boolean {
return Boolean(env.DASHBOARD_AGENT_SECRET_KEY);
}
// Pins every agent session (and its continuation runs) to a deployed version
// when DASHBOARD_AGENT_VERSION is set; unset runs on the env's current version.
export function dashboardAgentTriggerConfig(): { lockToVersion: string } | undefined {
return env.DASHBOARD_AGENT_VERSION ? { lockToVersion: env.DASHBOARD_AGENT_VERSION } : undefined;
}
export async function startDashboardAgentSession(params: {
chatId: string;
clientData?: Record<string, unknown>;
}): Promise<{ publicAccessToken: string }> {
const config = dashboardAgentConfig();
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
const startSession = chat.createStartSessionAction(TASK_ID, {
apiClient: config,
triggerConfig: dashboardAgentTriggerConfig(),
});
return startSession({ chatId: params.chatId, clientData: params.clientData });
}
export async function mintDashboardAgentToken(chatId: string): Promise<string> {
const config = dashboardAgentConfig();
if (!config) throw new Error("DASHBOARD_AGENT_SECRET_KEY is not set");
const client = new TriggerClient(config);
return client.auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
expirationTime: "1h",
});
}
// A signed, short-lived pointer to the project's connected repo at a commit. Only
// the URL crosses to the agent; the GitHub token stays here. The agent's code
// tools download + extract it on their own filesystem (see @internal/dashboard-agent).
export type DashboardAgentRepoSnapshot = {
tarballUrl: string;
owner: string;
repo: string;
sha: string;
defaultBranch?: string;
};
// The GitHub archive redirect URL is valid for a few minutes; cache the resolved
// pointer briefly so multi-turn chats don't re-mint a token + re-resolve on every
// message. Keyed by project + ref.
const repoSnapshotCache = new Map<
string,
{ snapshot: DashboardAgentRepoSnapshot; expiresAt: number }
>();
const REPO_SNAPSHOT_TTL_MS = 60_000;
const REPO_SNAPSHOT_MAX_ENTRIES = 1_000;
// Drop expired entries (key cardinality grows with each unique project + pinned
// SHA), then evict oldest-first if still over the cap, so the cache can't grow
// unbounded over a process lifetime.
function pruneRepoSnapshotCache(now = Date.now()) {
for (const [key, value] of repoSnapshotCache) {
if (value.expiresAt <= now) repoSnapshotCache.delete(key);
}
let overflow = repoSnapshotCache.size - REPO_SNAPSHOT_MAX_ENTRIES;
if (overflow <= 0) return;
for (const key of repoSnapshotCache.keys()) {
repoSnapshotCache.delete(key);
if (--overflow <= 0) break;
}
}
/**
* Resolve the code-mode repo snapshot for a project, or null when the GitHub App
* is disabled / no repo is connected (which keeps the agent in assistant mode).
*
* Mints a `contents:read` installation token scoped to the one repo, resolves the
* signed archive URL, and returns just that URL. The token never leaves the
* server. `opts.ref` pins a specific commit (run-SHA pinning); without it, the
* tracked prod branch (or the repo default) head is used.
*/
export async function resolveDashboardAgentRepoSnapshot(
projectId: string,
opts: { ref?: string } = {}
): Promise<DashboardAgentRepoSnapshot | null> {
if (!githubApp) return null;
// Cache per project + ref so HEAD and each pinned commit are cached separately.
const cacheKey = `${projectId}:${opts.ref ?? "HEAD"}`;
const cached = repoSnapshotCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.snapshot;
if (cached) repoSnapshotCache.delete(cacheKey);
const connected = await prisma.connectedGithubRepository.findFirst({
where: { projectId },
select: {
branchTracking: true,
repository: {
select: {
fullName: true,
defaultBranch: true,
installation: { select: { appInstallationId: true } },
},
},
},
});
if (!connected) return null;
const [owner, repo] = connected.repository.fullName.split("/");
if (!owner || !repo) return null;
const installationId = Number(connected.repository.installation.appInstallationId);
const defaultBranch = connected.repository.defaultBranch;
const tracking = connected.branchTracking as { prod?: { branch?: string } } | null;
// An explicit 40-char commit SHA is used directly (run-SHA pinning); otherwise
// resolve the requested branch, the tracked prod branch, or the repo default.
const requested = opts.ref;
const isSha = !!requested && /^[0-9a-f]{40}$/i.test(requested);
const branchRef = requested && !isSha ? requested : tracking?.prod?.branch || defaultBranch;
try {
const octokit = await githubApp.getInstallationOctokit(installationId);
const sha = isSha
? requested!
: (await octokit.rest.repos.getBranch({ owner, repo, branch: branchRef })).data.commit.sha;
const token = await githubApp.octokit.rest.apps.createInstallationAccessToken({
installation_id: installationId,
repositories: [repo],
permissions: { contents: "read" },
});
// Resolve the signed archive URL without downloading the bytes server-side.
const redirect = await fetch(`https://api.github.com/repos/${owner}/${repo}/tarball/${sha}`, {
headers: {
Authorization: `Bearer ${token.data.token}`,
Accept: "application/vnd.github+json",
"User-Agent": "trigger-dashboard-agent",
},
redirect: "manual",
});
const tarballUrl = redirect.headers.get("location");
if (!tarballUrl) return null;
const snapshot: DashboardAgentRepoSnapshot = { tarballUrl, owner, repo, sha, defaultBranch };
pruneRepoSnapshotCache();
repoSnapshotCache.set(cacheKey, { snapshot, expiresAt: Date.now() + REPO_SNAPSHOT_TTL_MS });
return snapshot;
} catch (error) {
logger.error("Failed to resolve dashboard agent repo snapshot", { error, projectId });
return null;
}
}
// Map a run (by friendly id) to the commit its deployed version came from, for
// run-SHA pinning. A run locks to a BackgroundWorker (`lockedToVersionId`), whose
// WorkerDeployment carries the commit. Null for runs with no deployed version
// (e.g. dev runs), so the agent falls back to the branch head.
export async function resolveRunCommit(
environmentId: string,
runFriendlyId: string
): Promise<{ sha: string; version: string; dirty: boolean } | null> {
const run = await runStore.findRun(
{ friendlyId: runFriendlyId, runtimeEnvironmentId: environmentId },
{ select: { lockedToVersionId: true } }
);
if (!run?.lockedToVersionId) return null;
const deployment = await prisma.workerDeployment.findFirst({
where: { workerId: run.lockedToVersionId },
select: { commitSHA: true, version: true, git: true },
});
if (!deployment?.commitSHA) return null;
const dirty = (deployment.git as { dirty?: boolean } | null)?.dirty ?? false;
return { sha: deployment.commitSHA, version: deployment.version, dirty };
}
@@ -0,0 +1,20 @@
import { createDashboardAgentDb, type DashboardAgentDb } from "@internal/dashboard-agent-db";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
/**
* The webapp's connection to the dashboard-agent conversation store (the History
* tab + the chat panel's create / rename / delete / resume actions). Same Drizzle
* client the agent task uses, pointed at the same database.
*
* This is the agent's OWN datastore — NOT the main Prisma database, which the
* agent has no access to. Cloud uses the dedicated PlanetScale database; OSS
* falls back to DATABASE_URL with tables isolated in the `trigger_dashboard_agent`
* schema.
*/
export const dashboardAgentDb: DashboardAgentDb = singleton("dashboardAgentDb", () => {
const connectionString = env.DASHBOARD_AGENT_DATABASE_URL ?? env.DATABASE_URL;
return createDashboardAgentDb(connectionString, {
max: env.DATABASE_CONNECTION_LIMIT,
}).db;
});
@@ -0,0 +1,72 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import {
DASHBOARD_AGENT_CODE_SYSTEM_PROMPT,
DASHBOARD_AGENT_MODEL,
DASHBOARD_AGENT_SYSTEM_PROMPT,
dashboardAgentCodeToolSchemas,
dashboardAgentToolSchemas,
} from "@internal/dashboard-agent/tool-schemas";
import { chat as chatServer } from "@trigger.dev/sdk/chat-server";
import { streamText, type UIMessage } from "ai";
import { env } from "~/env.server";
import {
dashboardAgentApiOrigin,
dashboardAgentTriggerConfig,
} from "~/services/dashboardAgent.server";
import { logger } from "~/services/logger.server";
const TASK_ID = "dashboard-agent";
const anthropic = createAnthropic({ apiKey: env.ANTHROPIC_API_KEY });
/**
* Server-owned head start. The webapp generates the chatId and owns the chat
* record, then kicks off step 1 here via `chat.startHeadStart` (the detached
* flow): it creates the session (externalId = chatId), triggers the
* handover-prepare run, and streams step 1 into `session.out` in the background.
* The browser resumes that stream rather than streaming step 1 inline. Step 1
* runs the agent's SCHEMA-ONLY tools + the shared model/prompt for the mode the
* agent run will be in; the agent run picks up tool execution and step 2+.
*
* `metadata` (the delegated UAT + context) is merged into the run's wire payload
* server-side, so it reaches the agent without touching the browser.
*/
export async function startDashboardAgentHeadStart(params: {
chatId: string;
messages: UIMessage[];
mode: "assistant" | "code";
metadata: Record<string, unknown>;
}): Promise<void> {
const tools = params.mode === "code" ? dashboardAgentCodeToolSchemas : dashboardAgentToolSchemas;
const system =
params.mode === "code" ? DASHBOARD_AGENT_CODE_SYSTEM_PROMPT : DASHBOARD_AGENT_SYSTEM_PROMPT;
const { completion } = await chatServer.startHeadStart({
agentId: TASK_ID,
chatId: params.chatId,
messages: params.messages,
metadata: params.metadata,
triggerConfig: dashboardAgentTriggerConfig(),
// Scope session creation + the agent trigger to the agent's project/env. The
// Anthropic key here only powers the warm step-1 call.
apiClient: {
baseURL: dashboardAgentApiOrigin(),
accessToken: env.DASHBOARD_AGENT_SECRET_KEY,
},
run: async ({ chat: helper }) =>
streamText({
...helper.toStreamTextOptions({ tools }),
model: anthropic(DASHBOARD_AGENT_MODEL),
system,
}),
});
// The webapp is long-lived, so step 1's drain + the handover dispatch run in
// the background after this resolves (createSession + trigger have completed).
// Log a warm-step failure for observability: startHeadStart has already fired
// handover-skip so the agent run exits cleanly, but the client (mounted as
// streaming) then resumes an empty session.out, so the turn looks lost.
completion.catch((error) => {
logger.error("Dashboard agent head start failed", { chatId: params.chatId, error });
});
}
@@ -0,0 +1,232 @@
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { type UserFromSession } from "./session.server";
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
// Map for section collapsed states - keys are section identifiers
collapsedSections: z.record(z.string(), z.boolean()).optional(),
/** Organization-specific settings */
organizations: z
.record(
z.string(),
z.object({
orderedItems: z.record(z.string(), z.array(z.string())),
})
)
.optional(),
});
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
export type { SideMenuSectionId };
const DashboardPreferences = z.object({
version: z.literal("1"),
currentProjectId: z.string().optional(),
projects: z.record(
z.string(),
z.object({
currentEnvironment: z.object({ id: z.string() }),
})
),
sideMenu: SideMenuPreferences.optional(),
});
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
export function getDashboardPreferences(data?: any | null): DashboardPreferences {
if (!data) {
return {
version: "1",
projects: {},
};
}
const result = DashboardPreferences.safeParse(data);
if (!result.success) {
logger.error("Failed to parse DashboardPreferences", { data, error: result.error });
return {
version: "1",
projects: {},
};
}
return result.data;
}
export async function updateCurrentProjectEnvironmentId({
user,
projectId,
environmentId,
}: {
user: UserFromSession;
projectId: string;
environmentId: string;
}) {
if (user.isImpersonating) {
return;
}
//only update if the existing preferences are different
if (
user.dashboardPreferences.currentProjectId === projectId &&
user.dashboardPreferences.projects[projectId]?.currentEnvironment?.id === environmentId
) {
return;
}
//ok we need to update the preferences
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
currentProjectId: projectId,
projects: {
...user.dashboardPreferences.projects,
[projectId]: {
...user.dashboardPreferences.projects[projectId],
currentEnvironment: { id: environmentId },
},
},
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
export async function clearCurrentProject({ user }: { user: UserFromSession }) {
if (user.isImpersonating) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
currentProjectId: undefined,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
export async function updateSideMenuPreferences({
user,
isCollapsed,
sectionCollapsed,
}: {
user: UserFromSession;
isCollapsed?: boolean;
/** Update a specific section's collapsed state */
sectionCollapsed?: { sectionId: SideMenuSectionId; collapsed: boolean };
}) {
if (user.isImpersonating) {
return;
}
// Parse with schema to apply defaults, then overlay any new values
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
// Build the updated collapsedSections map
let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
if (sectionCollapsed) {
updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
}
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
collapsedSections: updatedCollapsedSections,
});
// Only update if something changed
const hasCollapsedSectionsChanged =
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
/** Get the stored item order for a specific list within an organization */
export function getItemOrder(
sideMenu: SideMenuPreferences | undefined,
organizationId: string,
listId: string
): string[] | undefined {
return sideMenu?.organizations?.[organizationId]?.orderedItems?.[listId];
}
export async function updateItemOrder({
user,
organizationId,
listId,
order,
}: {
user: UserFromSession;
organizationId: string;
listId: string;
order: string[];
}) {
if (user.isImpersonating) {
return;
}
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
const currentOrg = currentSideMenu.organizations?.[organizationId];
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
organizations: {
...currentSideMenu.organizations,
[organizationId]: {
...currentOrg,
orderedItems: {
...currentOrg?.orderedItems,
[listId]: order,
},
},
},
});
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
@@ -0,0 +1,39 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// ClickHouse config (kind = CLICKHOUSE)
// ---------------------------------------------------------------------------
/** V1: single secret-store key that supplies the ClickHouse connection URL. */
export const ClickhouseDataStoreConfigV1 = z.object({
version: z.literal(1),
data: z.object({
/** Key into the SecretStore that resolves to a ClickhouseConnection ({url}). */
secretKey: z.string(),
}),
});
export type ClickhouseDataStoreConfigV1 = z.infer<typeof ClickhouseDataStoreConfigV1>;
/** Discriminated union over version — extend by adding new literals here. */
export const ClickhouseDataStoreConfig = z.discriminatedUnion("version", [
ClickhouseDataStoreConfigV1,
]);
export type ClickhouseDataStoreConfig = z.infer<typeof ClickhouseDataStoreConfig>;
// ---------------------------------------------------------------------------
// Top-level per-kind union
// ---------------------------------------------------------------------------
/**
* Secrets are resolved to URLs at registry load time so the factory never
* needs to touch the secret store on the hot path.
*/
export type ParsedClickhouseDataStore = {
kind: "CLICKHOUSE";
url: string;
};
/** Union of all parsed data store types. Extend as new DataStoreKind values are added. */
export type ParsedDataStore = ParsedClickhouseDataStore;
@@ -0,0 +1,201 @@
import type { DataStoreKind, PrismaClient, PrismaReplicaClient } from "@trigger.dev/database";
import {
ClickhouseDataStoreConfig,
type ParsedDataStore,
} from "./organizationDataStoreConfigSchemas.server";
import { getSecretStore } from "../secrets/secretStore.server";
import { ClickhouseConnectionSchema } from "../clickhouse/clickhouseSecretSchemas.server";
export class OrganizationDataStoresRegistry {
/**
* Writer client — used by every method that mutates state
* (`addDataStore` / `updateDataStore` / `deleteDataStore` and their backing
* SecretStore writes). Must be the primary connection; replica-targeted
* writes are rejected by Postgres with code 25006 (read-only transaction).
*/
private _writer: PrismaClient;
/**
* Read client used by the polling `loadFromDatabase()` (and its
* `SecretStore.getSecret` lookups). Can be a replica — these are
* cache-fillers, not on hot user-facing paths.
*/
private _replica: PrismaClient | PrismaReplicaClient;
/** Keyed by `${organizationId}:${kind}` */
private _lookup: Map<string, ParsedDataStore> = new Map();
private _loaded = false;
private _readyResolve!: () => void;
/**
* Resolves once the initial `loadFromDatabase()` completes successfully.
* At process startup the singleton loads the registry with unbounded retries
* (exponential backoff, capped delay) until Postgres is reachable; until then
* this promise stays pending and callers that await readiness will block.
*/
readonly isReady: Promise<void>;
constructor(writer: PrismaClient, replica: PrismaClient | PrismaReplicaClient) {
this._writer = writer;
this._replica = replica;
this.isReady = new Promise<void>((resolve) => {
this._readyResolve = resolve;
});
}
get isLoaded(): boolean {
return this._loaded;
}
async loadFromDatabase(): Promise<void> {
// Sort by `key` (unique, immutable) to ensure a deterministic winner when the
// same `${orgId}:${kind}` appears in multiple rows. The registry must never
// throw on overlap — failing the load would break every customer, not just the
// misconfigured orgs — so we keep the first entry and log an error instead.
const rows = await this._replica.organizationDataStore.findMany({
orderBy: { key: "asc" },
});
const secretStore = getSecretStore("DATABASE", { prismaClient: this._replica });
const lookup = new Map<string, ParsedDataStore>();
/** Tracks which row's `key` already owns each `${orgId}:${kind}` so we can log conflicts. */
const winnerByLookupKey = new Map<string, string>();
for (const row of rows) {
let parsed: ParsedDataStore | null = null;
switch (row.kind) {
case "CLICKHOUSE": {
const result = ClickhouseDataStoreConfig.safeParse(row.config);
if (!result.success) {
console.warn(
`[OrganizationDataStoresRegistry] Invalid config for OrganizationDataStore "${row.key}" (kind=CLICKHOUSE): ${result.error.message}`
);
continue;
}
const connection = await secretStore.getSecret(
ClickhouseConnectionSchema,
result.data.data.secretKey
);
if (!connection) {
console.warn(
`[OrganizationDataStoresRegistry] Secret "${result.data.data.secretKey}" not found for OrganizationDataStore "${row.key}" — skipping`
);
continue;
}
parsed = { kind: "CLICKHOUSE", url: connection.url };
break;
}
default: {
console.warn(
`[OrganizationDataStoresRegistry] Unknown kind "${row.kind}" for OrganizationDataStore "${row.key}" — skipping`
);
continue;
}
}
for (const orgId of row.organizationIds) {
const lookupKey = `${orgId}:${row.kind}`;
const existingWinner = winnerByLookupKey.get(lookupKey);
if (existingWinner) {
console.error(
`[OrganizationDataStoresRegistry] Overlapping OrganizationDataStore assignment for orgId="${orgId}" kind=${row.kind}: already routed to "${existingWinner}", ignoring "${row.key}". Pick one store per (org, kind) to resolve.`
);
continue;
}
winnerByLookupKey.set(lookupKey, row.key);
lookup.set(lookupKey, parsed);
}
}
this._lookup = lookup;
if (!this._loaded) {
this._loaded = true;
this._readyResolve();
}
}
async reload(): Promise<void> {
await this.loadFromDatabase();
}
#secretKey(key: string, kind: DataStoreKind) {
return `data-store:${key}:${kind.toLocaleLowerCase()}`;
}
async addDataStore({
key,
kind,
organizationIds,
config,
}: {
key: string;
kind: DataStoreKind;
organizationIds: string[];
config: any;
}) {
const secretKey = this.#secretKey(key, kind);
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
await secretStore.setSecret(secretKey, config);
return this._writer.organizationDataStore.create({
data: {
key,
organizationIds,
kind,
config: { version: 1, data: { secretKey } },
},
});
}
async updateDataStore({
key,
kind,
organizationIds,
config,
}: {
key: string;
kind: DataStoreKind;
organizationIds: string[];
config?: any;
}) {
const secretKey = this.#secretKey(key, kind);
if (config) {
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
await secretStore.setSecret(secretKey, config);
}
return this._writer.organizationDataStore.update({
where: {
key,
},
data: {
organizationIds,
kind: "CLICKHOUSE",
},
});
}
async deleteDataStore({ key, kind }: { key: string; kind: DataStoreKind }) {
const secretKey = this.#secretKey(key, kind);
const secretStore = getSecretStore("DATABASE", { prismaClient: this._writer });
await secretStore.deleteSecret(secretKey).catch(() => {
// Secret may not exist — proceed with deletion
});
await this._writer.organizationDataStore.delete({ where: { key } });
}
/**
* Returns the parsed data store config for the given organization and kind,
* or `null` if no override is configured (caller should use the default).
*/
get(organizationId: string, kind: DataStoreKind): ParsedDataStore | null {
if (!this._loaded) return null;
return this._lookup.get(`${organizationId}:${kind}`) ?? null;
}
}
@@ -0,0 +1,42 @@
import pRetry from "p-retry";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { signalsEmitter } from "~/services/signals.server";
import { singleton } from "~/utils/singleton";
import { OrganizationDataStoresRegistry } from "./organizationDataStoresRegistry.server";
export const organizationDataStoresRegistry = singleton("organizationDataStoresRegistry", () => {
const registry = new OrganizationDataStoresRegistry(prisma, $replica);
// Runs as soon as this singleton is created (first import of this module). The
// registrys `isReady` promise resolves when this eventually succeeds.
const startupLoadPromise = pRetry(() => registry.loadFromDatabase(), {
forever: true,
retries: 10,
minTimeout: 1_000,
maxTimeout: 60_000,
factor: 2,
onFailedAttempt: (error) => {
logger.warn("[OrganizationDataStoresRegistry] Startup load failed, retrying", {
attemptNumber: error.attemptNumber,
retriesLeft: error.retriesLeft,
error: error.message,
});
},
});
startupLoadPromise.catch((err) => {
console.error("[OrganizationDataStoresRegistry] Unexpected startup load failure", err);
});
const interval = setInterval(() => {
registry.reload().catch((err) => {
console.error("[OrganizationDataStoresRegistry] Failed to reload", err);
});
}, env.ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS);
signalsEmitter.on("SIGTERM", () => clearInterval(interval));
signalsEmitter.on("SIGINT", () => clearInterval(interval));
return registry;
});
@@ -0,0 +1,96 @@
import { runMigrations } from "graphile-worker";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { PgNotifyService } from "./pgNotify.server";
import { z } from "zod";
export class GraphileMigrationHelperService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call() {
this.#logDebug("GraphileMigrationHelperService.call");
await this.#detectAndPrepareForMigrations();
await runMigrations({
connectionString: env.DATABASE_URL,
schema: env.WORKER_SCHEMA,
});
}
#logDebug(message: string, args?: any) {
logger.debug(`[migrationHelper] ${message}`, args);
}
async #getLatestMigration() {
const migrationQueryResult = await this.#prismaClient.$queryRawUnsafe(`
SELECT id FROM ${env.WORKER_SCHEMA}.migrations
ORDER BY id DESC LIMIT 1
`);
const MigrationQueryResultSchema = z.array(z.object({ id: z.number() }));
const migrationResults = MigrationQueryResultSchema.parse(migrationQueryResult);
if (!migrationResults.length) {
// no migrations applied yet
return -1;
}
return migrationResults[0].id;
}
async #graphileSchemaExists() {
const schemaCount = await this.#prismaClient.$executeRaw`
SELECT schema_name FROM information_schema.schemata
WHERE schema_name = ${env.WORKER_SCHEMA}
`;
return schemaCount === 1;
}
/** Helper for graphile-worker v0.14.0 migration. No-op if already migrated. */
async #detectAndPrepareForMigrations() {
if (!(await this.#graphileSchemaExists())) {
// no schema yet, likely first start
return;
}
const latestMigration = await this.#getLatestMigration();
if (latestMigration < 0) {
// no migrations found
return;
}
// the first v0.14.0 migration has ID 11
if (latestMigration > 10) {
// already migrated
return;
}
// add 15s to graceful shutdown timeout, just to be safe
const migrationDelayInMs = env.GRACEFUL_SHUTDOWN_TIMEOUT + 15000;
this.#logDebug("Delaying worker startup due to pending migration", {
latestMigration,
migrationDelayInMs,
});
console.log(`⚠️ detected pending graphile migration`);
console.log(`⚠️ notifying running workers`);
const pgNotify = new PgNotifyService();
await pgNotify.call("trigger:graphile:migrate", { latestMigration });
console.log(`⚠️ delaying worker startup by ${migrationDelayInMs}ms`);
await new Promise((resolve) => setTimeout(resolve, migrationDelayInMs));
}
}
@@ -0,0 +1,28 @@
import type { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import type { NotificationCatalog, NotificationChannel } from "./types";
export class PgNotifyService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call<TChannel extends NotificationChannel>(
channelName: TChannel,
payload: z.infer<NotificationCatalog[TChannel]>
) {
this.#logDebug("Sending notification", { channelName, notifyPayload: payload });
await this.#prismaClient.$executeRaw`
SELECT pg_notify(${channelName}, ${JSON.stringify(payload)})
`;
}
#logDebug(message: string, args?: any) {
logger.debug(`[pgNotify] ${message}`, args);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
export const notificationCatalog = {
"trigger:graphile:migrate": z.object({
latestMigration: z.number(),
}),
};
export type NotificationCatalog = typeof notificationCatalog;
export type NotificationChannel = keyof NotificationCatalog;
@@ -0,0 +1,90 @@
import { DateFormatter } from "@internationalized/date";
import type { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { DeleteProjectService } from "./deleteProject.server";
import { getCurrentPlan } from "./platform.v3.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
export class DeleteOrganizationService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
organizationSlug,
userId,
request,
}: {
organizationSlug: string;
userId: string;
request: Request;
}) {
const organization = await this.#prismaClient.organization.findFirst({
include: {
projects: true,
members: true,
},
where: {
slug: organizationSlug,
members: { some: { userId: userId } },
},
});
if (!organization) {
throw new Error("Organization not found");
}
if (organization.deletedAt) {
throw new Error("Organization already deleted");
}
//Check if they have an active subscription
const { isManagedCloud } = featuresForRequest(request);
const currentPlan = isManagedCloud ? await getCurrentPlan(organization.id) : undefined;
if (currentPlan && currentPlan.v3Subscription && currentPlan.v3Subscription.isPaying) {
//they've cancelled and that date hasn't passed yet
if (
currentPlan.v3Subscription.canceledAt &&
new Date(currentPlan.v3Subscription.canceledAt) > new Date()
) {
//a dateformatter that produces results like "Jan 1 2024"
const dateFormatter = new DateFormatter("en-us", {
year: "numeric",
month: "short",
day: "numeric",
});
throw new Error(
`This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
new Date(currentPlan.v3Subscription.canceledAt)
)}) is in the past.`
);
}
throw new Error("You can't delete an Organization that has an active subscription");
}
// loop through the projects and delete them
const projectDeleteService = new DeleteProjectService();
for (const project of organization.projects) {
await projectDeleteService.call({ projectId: project.id, userId });
}
//mark the organization as deleted
await this.#prismaClient.organization.update({
where: {
id: organization.id,
},
data: {
runsEnabled: false,
deletedAt: new Date(),
},
});
// runsEnabled + the org's projects (project.deletedAt) changed; drop all cached env rows.
controlPlaneResolver.invalidateOrganization(organization.id);
}
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { marqs } from "~/v3/marqs/index.server";
import { engine } from "~/v3/runEngine.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
type Options = ({ projectId: string } | { projectSlug: string }) & {
userId: string;
};
export class DeleteProjectService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(options: Options) {
const projectId = await this.#getProjectId(options);
const project = await this.#prismaClient.project.findFirst({
include: {
environments: true,
organization: true,
},
where: {
id: projectId,
organization: { members: { some: { userId: options.userId } } },
},
});
if (!project) {
throw new Error("Project not found");
}
if (project.deletedAt) {
return;
}
// Remove queues from MARQS
for (const environment of project.environments) {
await marqs?.removeEnvironmentQueuesFromMasterQueue(project.organization.id, environment.id);
}
// Delete all queues from the RunEngine 2 prod master queues
for (const environment of project.environments) {
await engine.removeEnvironmentQueuesFromMasterQueue({
runtimeEnvironmentId: environment.id,
organizationId: project.organization.id,
projectId: project.id,
});
}
// Soft delete only: run-ops rows are intentionally retained (no hard-delete cascade here).
// Mark the project as deleted (do this last because it makes it impossible to try again)
// - This disables all API keys
// - This disables all schedules from being scheduled
await this.#prismaClient.project.update({
where: {
id: project.id,
},
data: {
deletedAt: new Date(),
},
});
// project.deletedAt (which gates env resolution) changed; drop every cached env of this project.
for (const environment of project.environments) {
controlPlaneResolver.invalidateEnvironment(environment.id);
}
}
async #getProjectId(options: Options) {
if ("projectId" in options) {
return options.projectId;
}
const { id } = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: options.projectSlug,
},
});
return id;
}
}
@@ -0,0 +1,152 @@
import type { DirectorySyncEffect } from "@trigger.dev/plugins";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";
import {
ensureOrgMember,
ensureUserForDirectory,
removeOrgMemberForDirectory,
} from "~/models/orgMember.server";
import { createPlatformNotification } from "~/services/platformNotifications.server";
const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected";
// Effects are idempotent and the worker retries, so a single failed attempt is
// transient (usually a serializable-conflict retry), not an alert. `logLevel`
// makes the worker log it at warn instead of paging.
function retryableEffectError(message: string): Error {
return Object.assign(new Error(message), { logLevel: "warn" as const });
}
// Deduped notification when the directory tried to remove the org's last Owner:
// we keep the member, and one undismissed notification is enough (no retry spam).
async function notifyLastOwnerProtected(userId: string, organizationId: string): Promise<void> {
const existing = await prisma.platformNotification.findFirst({
where: {
scope: "USER",
userId,
surface: "WEBAPP",
title: LAST_OWNER_NOTIFICATION_TITLE,
archivedAt: null,
endsAt: { gt: new Date() },
},
select: { id: true, interactions: { where: { userId }, select: { webappDismissedAt: true } } },
});
if (existing && !existing.interactions[0]?.webappDismissedAt) {
return;
}
const endsAt = new Date();
endsAt.setFullYear(endsAt.getFullYear() + 1);
const result = await createPlatformNotification({
title: LAST_OWNER_NOTIFICATION_TITLE,
surface: "WEBAPP",
scope: "USER",
userId,
endsAt: endsAt.toISOString(),
priority: 10,
payload: {
version: "1",
data: {
type: "card",
title: "Directory Sync kept your Owner access",
description:
"Your identity provider tried to remove you from this organization, but you are its only Owner. " +
"We kept your membership to prevent a lockout. Assign another Owner, then the directory change will apply.",
},
},
});
if (result.isErr()) {
logger.warn("directorySync: failed to create last-owner notification", {
userId,
organizationId,
error: result.error,
});
}
}
async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
switch (effect.kind) {
case "provision": {
const userId =
effect.userId ??
(
await ensureUserForDirectory({
email: effect.email,
firstName: effect.firstName,
lastName: effect.lastName,
})
).userId;
await ensureOrgMember({
userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
source: "directory_sync",
});
// Directory owns the role: overwrite even an existing member
// (ensureOrgMember only sets it on create).
if (effect.roleId) {
const result = await rbac.setUserRole({
userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
});
if (!result.ok) {
// The org must keep one Owner: skip the role overwrite for the last
// Owner (they keep Owner) instead of failing the whole batch. Applies
// to a directory burst and to a dashboard group remap alike.
if (result.code === "last_owner") {
logger.info("directorySync: kept last Owner, skipped provision role overwrite", {
userId,
organizationId: effect.organizationId,
});
} else {
throw retryableEffectError(
`directorySync provision setUserRole failed: ${result.error}`
);
}
}
}
return;
}
case "set_role": {
const result = await rbac.setUserRole({
userId: effect.userId,
organizationId: effect.organizationId,
roleId: effect.roleId,
});
if (!result.ok) {
// Keeping the org's last Owner is expected, not a failure — skip this
// one member and let the rest of the remap apply (no server error).
if (result.code === "last_owner") {
logger.info("directorySync: kept last Owner, skipped set_role", {
userId: effect.userId,
organizationId: effect.organizationId,
});
return;
}
throw retryableEffectError(`directorySync set_role failed: ${result.error}`);
}
return;
}
case "deprovision": {
const outcome = await removeOrgMemberForDirectory({
userId: effect.userId,
organizationId: effect.organizationId,
});
if (!outcome.removed && outcome.reason === "last_owner_protected") {
await notifyLastOwnerProtected(effect.userId, effect.organizationId);
}
return;
}
}
}
export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise<void> {
for (const effect of effects) {
await applyEffect(effect);
}
}
+100
View File
@@ -0,0 +1,100 @@
import type { DeliverEmail, SendPlainTextOptions, MailTransportOptions } from "emails";
import { EmailClient } from "emails";
import type { SendEmailOptions } from "remix-auth-email-link";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.server";
import { singleton } from "~/utils/singleton";
import { assertEmailAllowed } from "~/utils/email";
const client = singleton(
"email-client",
() =>
new EmailClient({
transport: buildTransportOptions(),
imagesBaseUrl: env.APP_ORIGIN,
from: env.FROM_EMAIL ?? "team@email.trigger.dev",
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
})
);
const alertsClient = singleton(
"alerts-email-client",
() =>
new EmailClient({
transport: buildTransportOptions(true),
imagesBaseUrl: env.APP_ORIGIN,
from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev",
// Fallback to `REPLY_TO_EMAIL` for backwards compat
replyTo: env.ALERT_REPLY_TO_EMAIL ?? env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
})
);
function buildTransportOptions(alerts?: boolean): MailTransportOptions {
const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT;
logger.debug(
`Constructing email transport '${transportType}' for usage '${alerts ? "alerts" : "general"}'`
);
switch (transportType) {
case "aws-ses":
return { type: "aws-ses" };
case "resend":
return {
type: "resend",
config: {
apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY,
},
};
case "smtp":
return {
type: "smtp",
config: {
host: alerts ? env.ALERT_SMTP_HOST : env.SMTP_HOST,
port: alerts ? env.ALERT_SMTP_PORT : env.SMTP_PORT,
secure: alerts ? env.ALERT_SMTP_SECURE : env.SMTP_SECURE,
auth: {
user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER,
pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD,
},
},
};
default:
return { type: undefined };
}
}
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
assertEmailAllowed(options.emailAddress);
// Auto redirect when in development mode
if (env.NODE_ENV === "development") {
throw redirect(options.magicLink);
}
logger.debug("Sending magic link email", { emailAddress: options.emailAddress });
try {
return await client.send({
email: "magic_link",
to: options.emailAddress,
magicLink: options.magicLink,
});
} catch (error) {
logger.error("Error sending magic link email", { error: JSON.stringify(error) });
throw error;
}
}
export async function sendPlainTextEmail(options: SendPlainTextOptions) {
return client.sendPlainText(options);
}
export async function sendEmail(data: DeliverEmail) {
return client.send(data);
}
export async function sendAlertEmail(data: DeliverEmail) {
return alertsClient.send(data);
}
@@ -0,0 +1,61 @@
import type { Authenticator } from "remix-auth";
import { EmailLinkStrategy } from "remix-auth-email-link";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import { sendMagicLinkEmail } from "~/services/email.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.server";
import { postAuthentication } from "./postAuth.server";
import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server";
let secret = env.MAGIC_LINK_SECRET;
if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable.");
const emailStrategy = new EmailLinkStrategy(
{
sendEmail: sendMagicLinkEmail,
secret,
callbackURL: "/magic",
sessionMagicLinkKey: "triggerdotdev:magiclink",
},
async ({
email,
form,
magicLinkVerify,
}: {
email: string;
form: FormData;
magicLinkVerify: boolean;
}) => {
logger.info("Magic link user authenticated", { email, magicLinkVerify });
// Gate the link CLICK, not just the send: a magic link issued before
// SSO enforcement flipped on (or replayed within its validity
// window) must not mint a session for an enforced domain.
if (magicLinkVerify) {
const ssoRedirect = await ssoRedirectForEmail(email, "domain_policy");
if (ssoRedirect) {
throw new SsoRequiredError(ssoRedirect);
}
}
try {
const { user, isNewUser } = await findOrCreateUser({
email,
authenticationMethod: "MAGIC_LINK",
});
await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" });
return { userId: user.id };
} catch (error) {
logger.debug("Magic link user failed to authenticate", { error: JSON.stringify(error) });
throw error;
}
}
);
export function addEmailLinkStrategy(authenticator: Authenticator<AuthUser>) {
authenticator.use(emailStrategy);
}
@@ -0,0 +1,34 @@
import { env } from "~/env.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import type { Duration } from "./rateLimiter.server";
export const engineRateLimiter = authorizationRateLimitMiddleware({
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
keyPrefix: "engine",
defaultLimiter: {
type: "tokenBucket",
refillRate: env.RUN_ENGINE_RATE_LIMIT_REFILL_RATE,
interval: env.RUN_ENGINE_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.RUN_ENGINE_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
maxItems: 1000,
},
pathMatchers: [/^\/engine/],
// Regex allow any path starting with /engine/v1/worker-actions/
pathWhiteList: [/^\/engine\/v1\/worker-actions\/.*/],
log: {
rejections: env.RUN_ENGINE_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.RUN_ENGINE_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.RUN_ENGINE_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
@@ -0,0 +1,233 @@
import { type ClickHouse } from "@internal/clickhouse";
import type { TaskRunStatus } from "@trigger.dev/database";
import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
export type DailyTaskActivity = Record<string, ({ day: string } & Record<TaskRunStatus, number>)[]>;
export type CurrentRunningStats = Record<string, { queued: number; running: number }>;
export type AverageDurations = Record<string, number>;
export interface EnvironmentMetricsRepository {
getDailyTaskActivity(options: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<DailyTaskActivity>;
getCurrentRunningStats(options: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<CurrentRunningStats>;
getAverageDurations(options: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<AverageDurations>;
}
export type ClickHouseEnvironmentMetricsRepositoryOptions = {
clickhouse: ClickHouse;
};
export class ClickHouseEnvironmentMetricsRepository implements EnvironmentMetricsRepository {
constructor(private readonly options: ClickHouseEnvironmentMetricsRepositoryOptions) {}
public async getDailyTaskActivity({
organizationId,
projectId,
environmentId,
days,
tasks,
}: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<DailyTaskActivity> {
if (tasks.length === 0) {
return {};
}
const [queryError, activity] = await this.options.clickhouse.taskRuns.getTaskActivity({
organizationId,
projectId,
environmentId,
days,
});
if (queryError) {
throw queryError;
}
return fillInDailyTaskActivity(
activity.map((a) => ({
taskIdentifier: a.task_identifier,
status: a.status as TaskRunStatus,
day: new Date(a.day),
count: BigInt(a.count),
})),
days
);
}
public async getCurrentRunningStats({
organizationId,
projectId,
environmentId,
days,
tasks,
}: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<CurrentRunningStats> {
if (tasks.length === 0) {
return {};
}
const [queryError, stats] = await this.options.clickhouse.taskRuns.getCurrentRunningStats({
organizationId,
projectId,
environmentId,
days,
});
if (queryError) {
throw queryError;
}
return fillInCurrentRunningStats(
stats.map((s) => ({
taskIdentifier: s.task_identifier,
status: s.status as TaskRunStatus,
count: BigInt(s.count),
})),
tasks
);
}
public async getAverageDurations({
organizationId,
projectId,
environmentId,
days,
tasks,
}: {
organizationId: string;
projectId: string;
environmentId: string;
days: number;
tasks: string[];
}): Promise<AverageDurations> {
if (tasks.length === 0) {
return {};
}
const [queryError, durations] = await this.options.clickhouse.taskRuns.getAverageDurations({
organizationId,
projectId,
environmentId,
days,
});
if (queryError) {
throw queryError;
}
return Object.fromEntries(durations.map((d) => [d.task_identifier, Number(d.duration)]));
}
}
type TaskActivityResults = Array<{
taskIdentifier: string;
status: TaskRunStatus;
day: Date;
count: bigint;
}>;
function fillInDailyTaskActivity(activity: TaskActivityResults, days: number): DailyTaskActivity {
//today with no time
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
return activity.reduce((acc, a) => {
let existingTask = acc[a.taskIdentifier];
if (!existingTask) {
existingTask = [];
//populate the array with the past 7 days
for (let i = days; i >= 0; i--) {
const day = new Date(today);
day.setUTCDate(today.getDate() - i);
day.setUTCHours(0, 0, 0, 0);
existingTask.push({
day: day.toISOString(),
["COMPLETED_SUCCESSFULLY"]: 0,
} as { day: string } & Record<TaskRunStatus, number>);
}
acc[a.taskIdentifier] = existingTask;
}
const dayString = a.day.toISOString();
const day = existingTask.find((d) => d.day === dayString);
if (!day) {
return acc;
}
day[a.status] = Number(a.count);
return acc;
}, {} as DailyTaskActivity);
}
type CurrentRunningStatsResults = Array<{
taskIdentifier: string;
status: TaskRunStatus;
count: bigint;
}>;
function fillInCurrentRunningStats(
stats: CurrentRunningStatsResults,
tasks: string[]
): CurrentRunningStats {
//create an object combining the queued and concurrency counts
const result: Record<string, { queued: number; running: number }> = {};
for (const task of tasks) {
const queued = stats.filter(
(q) => q.taskIdentifier === task && QUEUED_STATUSES.includes(q.status)
);
const queuedCount =
queued.length === 0
? 0
: queued.reduce((acc, q) => {
return acc + Number(q.count);
}, 0);
const running = stats.filter((r) => r.taskIdentifier === task && r.status === "EXECUTING");
const runningCount =
running.length === 0
? 0
: running.reduce((acc, r) => {
return acc + Number(r.count);
}, 0);
result[task] = {
queued: queuedCount,
running: runningCount,
};
}
return result;
}
@@ -0,0 +1,87 @@
import { json } from "@remix-run/server-runtime";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { isUserActorToken } from "@trigger.dev/rbac";
import { rbac } from "~/services/rbac.server";
type EnvironmentScopedResource = "envvars" | "apiKeys";
const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
envvars: "environment variables",
apiKeys: "API keys",
};
/**
* Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints
* that hand out an environment's secret credentials).
*
* Machine credentials (an environment's secret/public API key) are already
* scoped to a single environment, so they pass through unchanged. A personal
* access token (or a delegated user-actor token) carries a user, so enforce
* that user's role for the targeted environment tier — e.g. a Developer can't
* read deployed env vars or API keys via the API, matching the dashboard
* restriction. Blocking the credential read for deployed tiers is also what
* stops a restricted role deploying via the CLI (deploy needs the
* environment's secret key).
*
* Returns a `Response` to short-circuit with when access is denied, or
* `undefined` when the request may proceed.
*/
export async function authorizePatEnvironmentAccess({
request,
authType,
organizationId,
projectId,
envType,
resource,
action,
}: {
request: Request;
authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
organizationId: string;
projectId: string;
envType: RuntimeEnvironmentType;
resource: EnvironmentScopedResource;
action: "read" | "write";
}): Promise<Response | undefined> {
const bearer = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
const isUat = !!bearer && isUserActorToken(bearer);
// Machine creds (apiKey) and org tokens carry no user role to enforce. A
// user-actor token carries a user just like a PAT, so it's gated too.
if (authType !== "personalAccessToken" && !isUat) {
return undefined;
}
const userAuth = isUat
? await rbac.authenticateUserActor(request, { organizationId, projectId })
: await rbac.authenticatePat(request, { organizationId, projectId });
if (!userAuth.ok) {
return json({ error: userAuth.error }, { status: userAuth.status });
}
if (!userAuth.ability.can(action, { type: resource, envType })) {
return json(
{
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
},
{ status: 403 }
);
}
return undefined;
}
/** Env-tier env var access for the env var API routes. */
export function authorizeEnvVarApiRequest(opts: {
request: Request;
authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
organizationId: string;
projectId: string;
envType: RuntimeEnvironmentType;
action: "read" | "write";
}): Promise<Response | undefined> {
return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" });
}
+190
View File
@@ -0,0 +1,190 @@
import { App, type Octokit } from "octokit";
import { env } from "../env.server";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
export const githubApp =
env.GITHUB_APP_ENABLED === "1"
? new App({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
webhooks: {
secret: env.GITHUB_APP_WEBHOOK_SECRET,
},
})
: null;
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function linkGitHubAppInstallation(
installationId: number,
organizationId: string
): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const repositories = await fetchInstallationRepositories(octokit, installationId);
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
await prisma.githubAppInstallation.create({
data: {
appInstallationId: installationId,
organizationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
repositorySelection,
repositories: {
create: repositories,
},
},
});
}
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId },
});
if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
// repos are updated asynchronously via webhook events
await prisma.githubAppInstallation.update({
where: { id: existingInstallation?.id },
data: {
appInstallationId: installationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
suspendedAt: existingInstallation?.suspendedAt,
repositorySelection,
},
});
}
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, {
installation_id: installationId,
per_page: 100,
});
const allRepos = [];
const maxPages = 3;
let pageCount = 0;
for await (const { data } of iterator) {
pageCount++;
allRepos.push(...data);
if (maxPages && pageCount >= maxPages) {
logger.warn("GitHub installation repository fetch truncated", {
installationId,
maxPages,
totalReposFetched: allRepos.length,
});
break;
}
}
return allRepos.map((repo) => ({
githubId: repo.id,
name: repo.name,
fullName: repo.full_name,
htmlUrl: repo.html_url,
private: repo.private,
defaultBranch: repo.default_branch,
}));
}
/**
* Checks if a branch exists in a GitHub repository
*/
export function checkGitHubBranchExists(
installationId: number,
fullRepoName: string,
branch: string
): ResultAsync<boolean, { type: "other" | "github_app_not_enabled"; cause?: unknown }> {
if (!githubApp) {
return errAsync({ type: "github_app_not_enabled" as const });
}
if (!branch || branch.trim() === "") {
return okAsync(false);
}
const [owner, repo] = fullRepoName.split("/");
const getOctokit = () =>
fromPromise(githubApp.getInstallationOctokit(installationId), (error) => ({
type: "other" as const,
cause: error,
}));
const getBranch = (octokit: Octokit) =>
fromPromise(
octokit.rest.repos.getBranch({
owner,
repo,
branch,
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
return getOctokit()
.andThen((octokit) => getBranch(octokit))
.map(() => true)
.orElse((error) => {
if (
error.cause &&
error.cause instanceof Error &&
"status" in error.cause &&
error.cause.status === 404
) {
return okAsync(false);
}
return errAsync(error);
});
}
@@ -0,0 +1,65 @@
import type { Authenticator } from "remix-auth";
import { GitHubStrategy } from "remix-auth-github";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.server";
import { postAuthentication } from "./postAuth.server";
import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server";
export function addGitHubStrategy(
authenticator: Authenticator<AuthUser>,
clientID: string,
clientSecret: string
) {
const gitHubStrategy = new GitHubStrategy(
{
clientID,
clientSecret,
callbackURL: `${env.LOGIN_ORIGIN}/auth/github/callback`,
},
async ({ extraParams, profile }) => {
const emails = profile.emails;
if (!emails?.length) {
throw new Error("GitHub login requires an email address");
}
const email = emails[0].value;
// SSO auto-discovery gate — BEFORE findOrCreateUser, so an
// SSO-enforced domain never gets this GitHub identity linked onto
// an existing account.
const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked");
if (ssoRedirect) {
throw new SsoRequiredError(ssoRedirect);
}
try {
logger.debug("GitHub login", {
emails,
profile,
extraParams,
});
const { user, isNewUser } = await findOrCreateUser({
email,
authenticationMethod: "GITHUB",
authenticationProfile: profile,
authenticationExtraParams: extraParams,
});
await postAuthentication({ user, isNewUser, loginMethod: "GITHUB" });
return {
userId: user.id,
};
} catch (error) {
logger.error("GitHub login failed", { error: JSON.stringify(error) });
throw error;
}
}
);
authenticator.use(gitHubStrategy);
}
@@ -0,0 +1,124 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { randomBytes } from "crypto";
import { env } from "../env.server";
import { logger } from "./logger.server";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__github_app_install",
httpOnly: true,
maxAge: 60 * 60, // 1 hour
path: "/",
sameSite: "lax",
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
},
});
/**
* Creates a secure session for GitHub App installation with organization tracking
*/
export async function createGitHubAppInstallSession(
organizationId: string,
redirectTo: string
): Promise<{ url: string; cookieHeader: string }> {
if (env.GITHUB_APP_ENABLED !== "1") {
throw new Error("GitHub App is not enabled");
}
const state = randomBytes(32).toString("hex");
const session = await sessionStorage.getSession();
session.set("organizationId", organizationId);
session.set("redirectTo", redirectTo);
session.set("state", state);
session.set("createdAt", Date.now());
const githubAppSlug = env.GITHUB_APP_SLUG;
// the state query param gets passed through to the installation callback
const url = `https://github.com/apps/${githubAppSlug}/installations/new?state=${state}`;
const cookieHeader = await sessionStorage.commitSession(session);
return { url, cookieHeader };
}
/**
* Validates and retrieves the GitHub App installation session
*/
export async function validateGitHubAppInstallSession(
cookieHeader: string | null,
state: string
): Promise<
{ valid: true; organizationId: string; redirectTo: string } | { valid: false; error?: string }
> {
if (!cookieHeader) {
return {
valid: false,
error: "No installation session cookie found",
};
}
const session = await sessionStorage.getSession(cookieHeader);
const sessionState = session.get("state");
const organizationId = session.get("organizationId");
const redirectTo = session.get("redirectTo");
const createdAt = session.get("createdAt");
if (!sessionState || !organizationId || !createdAt || !redirectTo) {
logger.warn("GitHub App installation session missing required fields", {
hasState: !!sessionState,
hasOrgId: !!organizationId,
hasCreatedAt: !!createdAt,
hasRedirectTo: !!redirectTo,
});
return {
valid: false,
error: "invalid_session_data",
};
}
if (sessionState !== state) {
logger.warn("GitHub App installation state mismatch", {
expectedState: sessionState,
receivedState: state,
});
return {
valid: false,
error: "state_mismatch",
};
}
const expirationTime = createdAt + 60 * 60 * 1000;
if (Date.now() > expirationTime) {
logger.warn("GitHub App installation session expired", {
createdAt: new Date(createdAt),
now: new Date(),
});
return {
valid: false,
error: "session_expired",
};
}
return {
valid: true,
organizationId,
redirectTo,
};
}
/**
* Destroys the GitHub App installation cookie session
*/
export async function destroyGitHubAppInstallSession(cookieHeader: string | null): Promise<string> {
if (!cookieHeader) {
return "";
}
const session = await sessionStorage.getSession(cookieHeader);
return await sessionStorage.destroySession(session);
}
@@ -0,0 +1,74 @@
import type { Authenticator } from "remix-auth";
import { GoogleStrategy } from "remix-auth-google";
import { env } from "~/env.server";
import { findOrCreateUser } from "~/models/user.server";
import type { AuthUser } from "./authUser";
import { isGoogleEmailVerified } from "./googleEmailVerification";
import { logger } from "./logger.server";
import { postAuthentication } from "./postAuth.server";
import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server";
export function addGoogleStrategy(
authenticator: Authenticator<AuthUser>,
clientID: string,
clientSecret: string
) {
const googleStrategy = new GoogleStrategy(
{
clientID,
clientSecret,
callbackURL: `${env.LOGIN_ORIGIN}/auth/google/callback`,
},
async ({ extraParams, profile }) => {
const emails = profile.emails;
if (!emails?.length) {
throw new Error("Google login requires an email address");
}
const email = emails[0].value;
// Only trust the email if Google asserts it's verified, since account
// linking keys off it. See isGoogleEmailVerified.
if (!isGoogleEmailVerified(profile)) {
throw new Error(
"Google login refused: the Google account's email is not verified. Sign in with an account whose email Google has verified, or use magic-link / GitHub."
);
}
// SSO auto-discovery gate — BEFORE findOrCreateUser, so an
// SSO-enforced domain never gets this Google identity linked onto
// an existing account.
const ssoRedirect = await ssoRedirectForEmail(email, "oauth_blocked");
if (ssoRedirect) {
throw new SsoRequiredError(ssoRedirect);
}
try {
logger.debug("Google login", {
emails,
profile,
extraParams,
});
const { user, isNewUser } = await findOrCreateUser({
email,
authenticationMethod: "GOOGLE",
authenticationProfile: profile,
authenticationExtraParams: extraParams,
});
await postAuthentication({ user, isNewUser, loginMethod: "GOOGLE" });
return {
userId: user.id,
};
} catch (error) {
logger.error("Google login failed", { error: JSON.stringify(error) });
throw error;
}
}
);
authenticator.use(googleStrategy);
}
@@ -0,0 +1,15 @@
import type { GoogleProfile } from "remix-auth-google";
/**
* Whether Google has asserted that the profile's email is verified. A
* successful OAuth flow proves control of the Google account, not ownership of
* the email it carries, and account linking keys off the email.
*
* Strict by design: only a real boolean `true` counts. A missing claim, missing
* `_json`, the string `"true"`, or a truthy `1` are all treated as unverified.
*/
export function isGoogleEmailVerified(profile: GoogleProfile): boolean {
const emailVerified = (profile as { _json?: { email_verified?: unknown } })?._json
?.email_verified;
return emailVerified === true;
}
@@ -0,0 +1,33 @@
import { AsyncLocalStorage } from "node:async_hooks";
export type HttpLocalStorage = {
requestId: string;
path: string;
host: string;
method: string;
abortController: AbortController;
};
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
export type RunWithHttpContextFunction = <T>(context: HttpLocalStorage, fn: () => T) => T;
export function runWithHttpContext<T>(context: HttpLocalStorage, fn: () => T): T {
return httpLocalStorage.run(context, fn);
}
export function getHttpContext(): HttpLocalStorage | undefined {
return httpLocalStorage.getStore();
}
// Fallback signal that is never aborted, safe for tests and non-Express contexts.
const neverAbortedSignal = new AbortController().signal;
/**
* Returns an AbortSignal wired to the Express response's "close" event.
* This bypasses the broken request.signal chain in @remix-run/express
* (caused by Node.js undici GC bug nodejs/node#55428).
*/
export function getRequestAbortSignal(): AbortSignal {
return httpLocalStorage.getStore()?.abortController.signal ?? neverAbortedSignal;
}
@@ -0,0 +1,30 @@
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import nodeCrypto from "node:crypto";
import { env } from "~/env.server";
export function generateHttpCallbackUrl(waitpointId: string, apiKey: string) {
const hash = generateHttpCallbackHash(waitpointId, apiKey);
return `${env.API_ORIGIN ?? env.APP_ORIGIN}/api/v1/waitpoints/tokens/${WaitpointId.toFriendlyId(
waitpointId
)}/callback/${hash}`;
}
function generateHttpCallbackHash(waitpointId: string, apiKey: string) {
const hmac = nodeCrypto.createHmac("sha256", apiKey);
hmac.update(waitpointId);
return hmac.digest("hex");
}
export function verifyHttpCallbackHash(waitpointId: string, hash: string, apiKey: string) {
const expectedHash = generateHttpCallbackHash(waitpointId, apiKey);
if (
hash.length === expectedHash.length &&
nodeCrypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"))
) {
return true;
}
return false;
}
@@ -0,0 +1,154 @@
import { createCookieSessionStorage, type Session } from "@remix-run/node";
import { SignJWT, jwtVerify, errors } from "jose";
import { randomUUID } from "node:crypto";
import { singleton } from "~/utils/singleton";
import { createRedisClient, type RedisClient } from "~/redis.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
export const impersonationSessionStorage = createCookieSessionStorage({
cookie: {
name: "__impersonate", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production", // enable this in prod only
maxAge: 60 * 60 * 24, // 1 day
},
});
export function getImpersonationSession(request: Request) {
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
}
export function commitImpersonationSession(session: Session) {
return impersonationSessionStorage.commitSession(session);
}
export async function getImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
return session.get("impersonatedUserId") as string | undefined;
}
export async function setImpersonationId(userId: string, request: Request) {
const session = await getImpersonationSession(request);
session.set("impersonatedUserId", userId);
return session;
}
export async function clearImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
session.unset("impersonatedUserId");
return session;
}
// Impersonation token utilities for CSRF protection
const IMPERSONATION_TOKEN_EXPIRY_SECONDS = 5 * 60; // 5 minutes
function getImpersonationTokenSecret(): Uint8Array {
return new TextEncoder().encode(env.SESSION_SECRET);
}
function getImpersonationTokenRedisClient(): RedisClient {
return singleton("impersonationTokenRedis", () =>
createRedisClient("impersonation:token", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
keyPrefix: "impersonation:token:",
})
);
}
/**
* Generate a signed one-time impersonation token for a user
*/
export async function generateImpersonationToken(userId: string): Promise<string> {
const secret = getImpersonationTokenSecret();
const now = Math.floor(Date.now() / 1000);
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(now + IMPERSONATION_TOKEN_EXPIRY_SECONDS)
.setIssuer("https://trigger.dev")
.setAudience("https://trigger.dev/admin")
.setJti(randomUUID())
.sign(secret);
return token;
}
/**
* Validate and consume an impersonation token (prevents replay attacks)
*/
export async function validateAndConsumeImpersonationToken(
token: string
): Promise<string | undefined> {
try {
const secret = getImpersonationTokenSecret();
// Verify the token signature and expiration
const { payload } = await jwtVerify(token, secret, {
issuer: "https://trigger.dev",
audience: "https://trigger.dev/admin",
});
const userId = payload.userId as string | undefined;
if (!userId || typeof userId !== "string") {
return undefined;
}
// Atomically mark the token as used to prevent replay attacks.
// Use the jti (a short UUID) as the Redis key rather than the full JWT string.
// If Redis is unavailable, fall back to JWT expiry as the only protection.
if (env.CACHE_REDIS_HOST) {
// Defensively reject tokens without a jti (e.g. issued before this change)
if (!payload.jti) {
logger.warn("Impersonation token missing jti claim, rejecting for safety");
return undefined;
}
try {
const redis = getImpersonationTokenRedisClient();
const result = await redis.set(
payload.jti,
"1",
"EX",
IMPERSONATION_TOKEN_EXPIRY_SECONDS,
"NX"
);
if (result !== "OK") {
// Token was already used
return undefined;
}
} catch (redisError) {
logger.warn(
"Redis unavailable for impersonation token tracking, relying on JWT expiry only",
{
error: redisError instanceof Error ? redisError.message : String(redisError),
}
);
}
}
return userId;
} catch (error) {
if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) {
return undefined;
}
logger.error("Error validating impersonation token", {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
@@ -0,0 +1,102 @@
import { Redis } from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
const KEY_PREFIX = "isw:";
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function buildKey(runFriendlyId: string, streamId: string): string {
return `${KEY_PREFIX}${runFriendlyId}:${streamId}`;
}
function initializeRedis(): Redis | undefined {
const host = env.CACHE_REDIS_HOST;
if (!host) {
return undefined;
}
return new Redis({
connectionName: "inputStreamWaitpointCache",
host,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
keyPrefix: "tr:",
enableAutoPipelining: true,
reconnectOnError: defaultReconnectOnError,
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
}
const redis = singleton("inputStreamWaitpointCache", initializeRedis);
/**
* Store a mapping from input stream to waitpoint ID in Redis.
* Called when `.wait()` creates a new waitpoint.
*/
export async function setInputStreamWaitpoint(
runFriendlyId: string,
streamId: string,
waitpointId: string,
ttlMs?: number
): Promise<void> {
if (!redis) return;
try {
const key = buildKey(runFriendlyId, streamId);
await redis.set(key, waitpointId, "PX", ttlMs ?? DEFAULT_TTL_MS);
} catch (error) {
logger.error("Failed to set input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
}
}
/**
* Get the waitpoint ID for an input stream without deleting it.
* Called from the `.send()` route before completing the waitpoint.
*/
export async function getInputStreamWaitpoint(
runFriendlyId: string,
streamId: string
): Promise<string | null> {
if (!redis) return null;
try {
const key = buildKey(runFriendlyId, streamId);
return await redis.get(key);
} catch (error) {
logger.error("Failed to get input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
return null;
}
}
/**
* Delete the cache entry for an input stream waitpoint.
* Called when a waitpoint is completed or timed out.
*/
export async function deleteInputStreamWaitpoint(
runFriendlyId: string,
streamId: string
): Promise<void> {
if (!redis) return;
try {
const key = buildKey(runFriendlyId, streamId);
await redis.del(key);
} catch (error) {
logger.error("Failed to delete input stream waitpoint cache", {
runFriendlyId,
streamId,
error,
});
}
}
@@ -0,0 +1,25 @@
import { createCookie } from "@remix-run/node";
import { env } from "~/env.server";
export type LastAuthMethod = "github" | "google" | "email" | "sso";
// Cookie that persists for 1 year to remember the user's last login method
export const lastAuthMethodCookie = createCookie("last-auth-method", {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
});
export async function getLastAuthMethod(request: Request): Promise<LastAuthMethod | null> {
const cookie = request.headers.get("Cookie");
const value = await lastAuthMethodCookie.parse(cookie);
if (value === "github" || value === "google" || value === "email" || value === "sso") {
return value;
}
return null;
}
export async function setLastAuthMethodHeader(method: LastAuthMethod): Promise<string> {
return lastAuthMethodCookie.serialize(method);
}
@@ -0,0 +1,22 @@
import { type Params } from "@remix-run/react";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
export async function loadProjectEnvironmentFromRequest(request: Request, params: Params<string>) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
return { userId, project, environment };
}
+98
View File
@@ -0,0 +1,98 @@
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import { patchConsoleToTelnet, startTelnetLogServer } from "@trigger.dev/core/v3/telnetLogServer";
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
import { AsyncLocalStorage } from "async_hooks";
import { getHttpContext } from "./httpAsyncStorage.server";
import { captureException, captureMessage } from "@sentry/remix";
const currentFieldsStore = new AsyncLocalStorage<Record<string, unknown>>();
export function trace<T>(fields: Record<string, unknown>, fn: () => T): T {
return currentFieldsStore.run(fields, fn);
}
Logger.onError = (message, ...args) => {
const error = extractErrorFromArgs(args);
if (error) {
captureException(error, {
extra: {
message,
...flattenArgs(args),
},
});
} else {
captureMessage(message, {
level: "error",
extra: flattenArgs(args),
});
}
};
function extractErrorFromArgs(args: Array<Record<string, unknown> | undefined>) {
for (const arg of args) {
if (arg && "error" in arg && arg.error instanceof Error) {
return arg.error;
}
}
return;
}
function flattenArgs(args: Array<Record<string, unknown> | undefined>) {
return args.reduce((acc, arg) => {
if (arg) {
return { ...acc, ...arg };
}
return acc;
}, {});
}
export const logger = new Logger(
"webapp",
(process.env.APP_LOG_LEVEL ?? "info") as LogLevel,
["examples", "output", "connectionString", "payload"],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
const httpContext = getHttpContext();
return { ...fields, http: httpContext };
}
);
export const workerLogger = new Logger(
"worker",
(process.env.APP_LOG_LEVEL ?? "info") as LogLevel,
["examples", "output", "connectionString"],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
return fields ? { ...fields } : {};
}
);
export const socketLogger = new Logger(
"socket",
(process.env.APP_LOG_LEVEL ?? "info") as LogLevel,
[],
sensitiveDataReplacer,
() => {
const fields = currentFieldsStore.getStore();
return fields ? { ...fields } : {};
}
);
// Opt-in, dev-only: mirror this process's stdout to a local telnet/TCP stream.
// We patch console (rather than the static Logger.onLog sink) so the stream also captures logs
// from separate/bundled copies of the Logger — e.g. the enterprise SSO plugin, which bundles its
// own @trigger.dev/core and logs via its own console.log, invisible to the webapp's onLog hook.
const telnetLogsPort = process.env.WEBAPP_TELNET_LOGS_PORT
? Number(process.env.WEBAPP_TELNET_LOGS_PORT)
: undefined;
if (telnetLogsPort && Number.isFinite(telnetLogsPort) && telnetLogsPort > 0) {
const telnetGlobal = globalThis as typeof globalThis & { __webappTelnetLogs?: boolean };
if (!telnetGlobal.__webappTelnetLogs) {
telnetGlobal.__webappTelnetLogs = true;
const telnetLogServer = startTelnetLogServer({ port: telnetLogsPort, name: "webapp" });
patchConsoleToTelnet(telnetLogServer, { pretty: true });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { env } from "~/env.server";
import { logger } from "./logger.server";
class LoopsClient {
constructor(private readonly apiKey: string) {}
async userCreated({
userId,
email,
name,
}: {
userId: string;
email: string;
name: string | null;
}) {
logger.info(`Loops send "sign-up" event`, { userId, email, name });
return this.#sendEvent({
email,
userId,
firstName: name?.split(" ").at(0),
eventName: "sign-up",
});
}
async #sendEvent({
email,
userId,
firstName,
eventName,
eventProperties,
}: {
email: string;
userId: string;
firstName?: string;
eventName: string;
eventProperties?: Record<string, string | number | boolean>;
}) {
const options = {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
userId,
firstName,
eventName,
eventProperties,
}),
};
try {
const response = await fetch("https://app.loops.so/api/v1/events/send", options);
if (!response.ok) {
logger.error(`Loops sendEvent ${eventName} bad status`, {
status: response.status,
email,
userId,
firstName,
eventProperties,
eventName,
});
return false;
}
const responseBody = (await response.json()) as any;
if (!responseBody.success) {
logger.error(`Loops sendEvent ${eventName} failed response`, {
message: responseBody.message,
});
return false;
}
return true;
} catch (error) {
logger.error(`Loops sendEvent ${eventName} failed`, { error });
return false;
}
}
}
export const loopsClient = env.LOOPS_API_KEY ? new LoopsClient(env.LOOPS_API_KEY) : null;
@@ -0,0 +1,96 @@
import { Ratelimit } from "@upstash/ratelimit";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
export class MagicLinkRateLimitError extends Error {
public readonly retryAfter: number;
constructor(retryAfter: number) {
super("Magic link request rate limit exceeded.");
this.retryAfter = retryAfter;
}
}
function getRedisClient() {
return createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
}
const magicLinkEmailRateLimiter = singleton(
"magicLinkEmailRateLimiter",
initializeMagicLinkEmailRateLimiter
);
function initializeMagicLinkEmailRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:email",
limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email
logSuccess: false,
logFailure: true,
});
}
const magicLinkEmailDailyRateLimiter = singleton(
"magicLinkEmailDailyRateLimiter",
initializeMagicLinkEmailDailyRateLimiter
);
function initializeMagicLinkEmailDailyRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:email:daily",
limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email
logSuccess: false,
logFailure: true,
});
}
const magicLinkIpRateLimiter = singleton(
"magicLinkIpRateLimiter",
initializeMagicLinkIpRateLimiter
);
function initializeMagicLinkIpRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:ip",
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP
logSuccess: false,
logFailure: true,
});
}
export async function checkMagicLinkEmailRateLimit(identifier: string): Promise<void> {
const result = await magicLinkEmailRateLimiter.limit(identifier);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise<void> {
const result = await magicLinkEmailDailyRateLimiter.limit(identifier);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
export async function checkMagicLinkIpRateLimit(ip: string): Promise<void> {
const result = await magicLinkIpRateLimiter.limit(ip);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
@@ -0,0 +1,628 @@
import type {
IOPacket,
RunMetadataChangeOperation,
UpdateMetadataRequestBody,
} from "@trigger.dev/core/v3";
import { applyMetadataOperations, parsePacket } from "@trigger.dev/core/v3";
import type { PrismaClientOrTransaction } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { handleMetadataPacket, MetadataTooLargeError } from "~/utils/packets";
import { ServiceValidationError } from "~/v3/services/common.server";
import { Effect, Schedule, Duration, Fiber } from "effect";
import { type RuntimeFiber } from "effect/Fiber";
import { setTimeout } from "timers/promises";
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import type { RunStore } from "@internal/run-store";
const RUN_UPDATABLE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
type BufferedRunMetadataChangeOperation = {
runId: string;
timestamp: number;
operation: RunMetadataChangeOperation;
};
export type UpdateMetadataServiceOptions = {
prisma: PrismaClientOrTransaction;
runStore: RunStore;
flushIntervalMs?: number;
flushEnabled?: boolean;
flushLoggingEnabled?: boolean;
maximumSize?: number;
logger?: Logger;
logLevel?: LogLevel;
/** Called after the batched flusher writes a run's buffered operations, with everything
* a realtime change record needs — buffered (parent/root) updates otherwise never wake
* live feeds. */
onRunFlushed?: (run: {
runId: string;
environmentId: string;
tags: string[];
batchId: string | null;
updatedAtMs: number;
}) => void;
// Testing hooks
onBeforeUpdate?: (runId: string) => Promise<void>;
onAfterRead?: (runId: string, metadataVersion: number) => Promise<void>;
};
export class UpdateMetadataService {
private _bufferedOperations: Map<string, BufferedRunMetadataChangeOperation[]> = new Map();
private _flushFiber: RuntimeFiber<void> | null = null;
private readonly _prisma: PrismaClientOrTransaction;
private readonly _runStore: RunStore;
private readonly flushIntervalMs: number;
private readonly flushEnabled: boolean;
private readonly flushLoggingEnabled: boolean;
private readonly maximumSize: number;
private readonly logger: Logger;
constructor(private readonly options: UpdateMetadataServiceOptions) {
this._prisma = options.prisma;
this._runStore = options.runStore;
this.flushIntervalMs = options.flushIntervalMs ?? 5000;
this.flushEnabled = options.flushEnabled ?? true;
this.flushLoggingEnabled = options.flushLoggingEnabled ?? true;
this.maximumSize = options.maximumSize ?? 1024 * 1024 * 1; // 1MB
this.logger = options.logger ?? new Logger("UpdateMetadataService", options.logLevel ?? "info");
this._startFlushing();
}
// Start a loop that periodically flushes buffered operations
private _startFlushing() {
if (!this.flushEnabled) {
this.logger.info("[UpdateMetadataService] 🚽 Flushing disabled");
return;
}
this.logger.info("[UpdateMetadataService] 🚽 Flushing started");
// Create a program that sleeps, then processes buffered ops
const program = Effect.gen(this, function* (_) {
while (true) {
// Wait for flushIntervalMs before flushing again
yield* _(Effect.sleep(Duration.millis(this.flushIntervalMs)));
// Atomically get and clear current operations
const currentOperations = new Map(this._bufferedOperations);
this._bufferedOperations.clear();
yield* Effect.sync(() => {
if (this.flushLoggingEnabled) {
this.logger.debug(`[UpdateMetadataService] Flushing operations`, {
operations: Object.fromEntries(currentOperations),
});
}
});
// If we have operations, process them
if (currentOperations.size > 0) {
yield* _(this._processBufferedOperations(currentOperations));
}
}
}).pipe(
// Handle any unexpected errors, ensuring program does not fail
Effect.catchAll((error) =>
Effect.sync(() => {
this.logger.error("Error in flushing program:", { error });
})
)
);
// Fork the program so it runs in the background
this._flushFiber = Effect.runFork(program as Effect.Effect<void, never, never>);
}
stopFlushing() {
if (this._flushFiber) {
Effect.runFork(Fiber.interrupt(this._flushFiber));
}
}
private _processBufferedOperations = (
operations: Map<string, BufferedRunMetadataChangeOperation[]>
) => {
return Effect.gen(this, function* (_) {
for (const [runId, ops] of operations) {
// Process and cull operations
const processedOps = this._cullOperations(ops);
// If there are no operations to process, skip
if (processedOps.length === 0) {
continue;
}
yield* Effect.sync(() => {
if (this.flushLoggingEnabled) {
this.logger.debug(`[UpdateMetadataService] Processing operations for run`, {
runId,
operationsCount: processedOps.length,
});
}
});
// Update run with retry
yield* _(
this._updateRunWithOperations(runId, processedOps).pipe(
// Catch MetadataTooLargeError before retry logic
Effect.catchIf(
(error) => error instanceof MetadataTooLargeError,
(error) =>
Effect.sync(() => {
// Log the error but don't return operations to buffer
console.error(
`[UpdateMetadataService] Dropping operations for run ${runId} due to metadata size limit:`,
error.message
);
if (this.flushLoggingEnabled) {
this.logger.warn(`[UpdateMetadataService] Metadata too large for run`, {
runId,
operationsCount: processedOps.length,
error: error.message,
});
}
})
),
Effect.retry(Schedule.exponential(Duration.millis(100), 1.4)),
Effect.catchAll((error) =>
Effect.sync(() => {
// On complete failure, return ops to buffer
const existingOps = this._bufferedOperations.get(runId) ?? [];
this._bufferedOperations.set(runId, [...existingOps, ...ops]);
console.error(`Failed to process run ${runId}:`, error);
})
)
)
);
}
});
};
private _updateRunWithOperations = (
runId: string,
operations: BufferedRunMetadataChangeOperation[]
) => {
return Effect.gen(this, function* (_) {
// Fetch current run (+ the realtime membership keys, so a flush can publish)
const run = yield* _(
Effect.tryPromise(() =>
this._runStore.findRun(
{ id: runId },
{
select: {
id: true,
metadata: true,
metadataType: true,
metadataVersion: true,
runtimeEnvironmentId: true,
runTags: true,
batchId: true,
},
},
this._prisma
)
)
);
if (!run) {
return yield* _(Effect.fail(new Error(`Run ${runId} not found`)));
}
// Testing hook after read
if (this.options.onAfterRead) {
yield* _(Effect.tryPromise(() => this.options.onAfterRead!(runId, run.metadataVersion)));
}
const metadata = yield* _(
Effect.tryPromise(() =>
run.metadata
? parsePacket({ data: run.metadata, dataType: run.metadataType })
: Promise.resolve({})
)
);
// Apply operations and update
const applyResult = applyMetadataOperations(
metadata,
operations.map((op) => op.operation)
);
if (applyResult.unappliedOperations.length === operations.length) {
this.logger.warn(`No operations applied for run ${runId}`);
// If no operations were applied, return
return;
}
// Stringify the metadata
const newMetadataPacket = yield* _(
Effect.try(() =>
handleMetadataPacket(applyResult.newMetadata, run.metadataType, this.maximumSize)
).pipe(
Effect.mapError((error) => {
// Preserve the original error if it's MetadataTooLargeError
if ("cause" in error && error.cause instanceof MetadataTooLargeError) {
return error.cause;
}
return error;
})
)
);
if (!newMetadataPacket) {
// Log and skip if metadata is invalid
this.logger.warn(`Invalid metadata after operations, skipping update`);
return;
}
// Testing hook before update
if (this.options.onBeforeUpdate) {
yield* _(Effect.tryPromise(() => this.options.onBeforeUpdate!(runId)));
}
// Stamp updatedAt explicitly so the realtime publish can carry the exact committed
// value without a follow-up read (updateMany can't RETURNING).
const writeTime = new Date();
const result = yield* _(
Effect.tryPromise(() =>
this._runStore.updateMetadata(
runId,
{
metadata: newMetadataPacket.data!,
metadataVersion: { increment: 1 },
updatedAt: writeTime,
},
{ expectedMetadataVersion: run.metadataVersion },
this._prisma
)
)
);
if (result.count === 0) {
yield* Effect.sync(() => {
this.logger.warn(`Optimistic lock failed for run ${runId}`, {
metadataVersion: run.metadataVersion,
});
});
return yield* _(Effect.fail(new Error("Optimistic lock failed")));
}
yield* Effect.sync(() => {
this.options.onRunFlushed?.({
runId,
environmentId: run.runtimeEnvironmentId,
tags: run.runTags,
batchId: run.batchId,
updatedAtMs: writeTime.getTime(),
});
});
return result;
});
};
private _cullOperations(
operations: BufferedRunMetadataChangeOperation[]
): BufferedRunMetadataChangeOperation[] {
// Sort by timestamp
const sortedOps = [...operations].sort((a, b) => a.timestamp - b.timestamp);
// Track latest set operations by key
const latestSetOps = new Map<string, BufferedRunMetadataChangeOperation>();
const resultOps: BufferedRunMetadataChangeOperation[] = [];
for (const op of sortedOps) {
if (op.operation.type === "set") {
latestSetOps.set(op.operation.key, op);
} else {
resultOps.push(op);
}
}
// Add winning set operations
resultOps.push(...latestSetOps.values());
return resultOps;
}
public async call(
runId: string,
body: UpdateMetadataRequestBody,
environment?: AuthenticatedEnvironment
) {
const runIdType = runId.startsWith("run_") ? "friendly" : "internal";
const taskRun = await this._runStore.findRun(
environment
? {
runtimeEnvironmentId: environment.id,
...(runIdType === "internal" ? { id: runId } : { friendlyId: runId }),
}
: {
...(runIdType === "internal" ? { id: runId } : { friendlyId: runId }),
},
{
select: {
id: true,
batchId: true,
runTags: true,
completedAt: true,
status: true,
metadata: true,
metadataType: true,
metadataVersion: true,
parentTaskRun: {
select: {
id: true,
status: true,
},
},
rootTaskRun: {
select: {
id: true,
status: true,
},
},
},
},
this._prisma
);
if (!taskRun) {
return;
}
if (!this.#isRunUpdatable(taskRun)) {
throw new ServiceValidationError("Cannot update metadata for a completed run");
}
if (body.parentOperations && body.parentOperations.length > 0) {
this.#ingestRunOperations(taskRun.parentTaskRun?.id ?? taskRun.id, body.parentOperations);
}
if (body.rootOperations && body.rootOperations.length > 0) {
this.#ingestRunOperations(taskRun.rootTaskRun?.id ?? taskRun.id, body.rootOperations);
}
const result = await this.#updateRunMetadata({
runId: taskRun.id,
body,
existingMetadata: {
data: taskRun.metadata ?? undefined,
dataType: taskRun.metadataType,
},
});
return {
metadata: result?.metadata,
// Internal id + membership keys, so callers can publish full realtime records the router routes by index.
runId: taskRun.id,
batchId: taskRun.batchId,
runTags: taskRun.runTags,
// The committed row's updatedAt — the realtime watermark. Undefined when nothing was
// written here (no-op, or buffered for the flusher, which publishes itself).
updatedAtMs: result?.updatedAtMs,
};
}
async #updateRunMetadata({
runId,
body,
existingMetadata,
}: {
runId: string;
body: UpdateMetadataRequestBody;
existingMetadata: IOPacket;
}): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
if (Array.isArray(body.operations)) {
return this.#updateRunMetadataWithOperations(runId, body.operations);
} else {
return this.#updateRunMetadataDirectly(runId, body, existingMetadata);
}
}
async #updateRunMetadataWithOperations(
runId: string,
operations: RunMetadataChangeOperation[]
): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
const MAX_RETRIES = 3;
let attempts = 0;
while (attempts <= MAX_RETRIES) {
// Fetch the latest run data
const run = await this._runStore.findRun(
{ id: runId },
{
select: { metadata: true, metadataType: true, metadataVersion: true },
},
this._prisma
);
if (!run) {
throw new Error(`Run ${runId} not found`);
}
// Testing hook after read
if (this.options.onAfterRead) {
await this.options.onAfterRead(runId, run.metadataVersion);
}
// Parse the current metadata
const currentMetadata = await (run.metadata
? parsePacket({ data: run.metadata, dataType: run.metadataType })
: Promise.resolve({}));
// Apply operations to the current metadata
const applyResults = applyMetadataOperations(currentMetadata, operations);
// If no operations were applied, return the current metadata (nothing written)
if (applyResults.unappliedOperations.length === operations.length) {
return { metadata: currentMetadata };
}
const newMetadataPacket = handleMetadataPacket(
applyResults.newMetadata,
run.metadataType,
this.maximumSize
);
if (!newMetadataPacket) {
throw new ServiceValidationError("Unable to update metadata");
}
// Testing hook before update
if (this.options.onBeforeUpdate) {
await this.options.onBeforeUpdate(runId);
}
// Update with optimistic locking; updatedAt stamped explicitly so the caller can
// publish the exact committed watermark without a follow-up read.
const writeTime = new Date();
const result = await this._runStore.updateMetadata(
runId,
{
metadata: newMetadataPacket.data!,
metadataType: newMetadataPacket.dataType,
metadataVersion: {
increment: 1,
},
updatedAt: writeTime,
},
{ expectedMetadataVersion: run.metadataVersion },
this._prisma
);
if (result.count === 0) {
if (this.flushLoggingEnabled) {
this.logger.debug(
`[updateRunMetadataWithOperations] Optimistic lock failed for run ${runId}`,
{
metadataVersion: run.metadataVersion,
}
);
}
// If this was our last attempt, buffer the operations and return optimistically
// (no watermark — the flusher writes later and publishes itself).
if (attempts === MAX_RETRIES) {
this.#ingestRunOperations(runId, operations);
return { metadata: applyResults.newMetadata };
}
// Otherwise sleep and try again
await setTimeout(100 * Math.pow(1.4, attempts));
attempts++;
continue;
}
if (this.flushLoggingEnabled) {
this.logger.debug(`[updateRunMetadataWithOperations] Updated metadata for run`, {
metadata: applyResults.newMetadata,
operations: operations,
runId,
});
}
// Success! Return the new metadata
return { metadata: applyResults.newMetadata, updatedAtMs: writeTime.getTime() };
}
return { metadata: undefined };
}
// Checks to see if a run is updatable
// if there is no completedAt, the run is updatable
// if the run is completed, but the completedAt is within the last 10 minutes, the run is updatable
#isRunUpdatable(run: { completedAt: Date | null }) {
if (!run.completedAt) {
return true;
}
return run.completedAt.getTime() > Date.now() - RUN_UPDATABLE_WINDOW_MS;
}
async #updateRunMetadataDirectly(
runId: string,
body: UpdateMetadataRequestBody,
existingMetadata: IOPacket
): Promise<{ metadata: Record<string, unknown> | undefined; updatedAtMs?: number }> {
const metadataPacket = handleMetadataPacket(
body.metadata,
"application/json",
this.maximumSize
);
if (!metadataPacket) {
return { metadata: {} };
}
let updatedAtMs: number | undefined;
if (
metadataPacket.data !== "{}" ||
(existingMetadata.data && metadataPacket.data !== existingMetadata.data)
) {
if (this.flushLoggingEnabled) {
this.logger.debug(`[updateRunMetadataDirectly] Updating metadata directly for run`, {
metadata: metadataPacket.data,
runId,
});
}
// Update the metadata without version check; updatedAt stamped explicitly so the
// caller can publish the exact committed watermark.
const writeTime = new Date();
await this._runStore.updateMetadata(
runId,
{
metadata: metadataPacket?.data!,
metadataType: metadataPacket?.dataType,
metadataVersion: {
increment: 1,
},
updatedAt: writeTime,
},
{},
this._prisma
);
updatedAtMs = writeTime.getTime();
}
const newMetadata = await parsePacket(metadataPacket);
return { metadata: newMetadata, updatedAtMs };
}
#ingestRunOperations(runId: string, operations: RunMetadataChangeOperation[]) {
const bufferedOperations: BufferedRunMetadataChangeOperation[] = operations.map((operation) => {
return {
runId,
timestamp: Date.now(),
operation,
};
});
if (this.flushLoggingEnabled) {
this.logger.debug(`[ingestRunOperations] Ingesting operations for run`, {
runId,
bufferedOperations,
});
}
const existingBufferedOperations = this._bufferedOperations.get(runId) ?? [];
this._bufferedOperations.set(runId, [...existingBufferedOperations, ...bufferedOperations]);
}
// Testing method to manually trigger flush
async flushOperations() {
const currentOperations = new Map(this._bufferedOperations);
this._bufferedOperations.clear();
if (currentOperations.size > 0) {
await Effect.runPromise(this._processBufferedOperations(currentOperations));
}
}
}
@@ -0,0 +1,31 @@
import { singleton } from "~/utils/singleton";
import { env } from "~/env.server";
import { UpdateMetadataService } from "./updateMetadata.server";
import { prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server";
export const updateMetadataService = singleton(
"update-metadata-service",
() =>
new UpdateMetadataService({
prisma,
runStore,
flushIntervalMs: env.BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS,
flushEnabled: env.BATCH_METADATA_OPERATIONS_FLUSH_ENABLED === "1",
flushLoggingEnabled: env.BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED === "1",
maximumSize: env.TASK_RUN_METADATA_MAXIMUM_SIZE,
logLevel: env.BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED === "1" ? "debug" : "info",
// Buffered (parent/root) operations land via the flusher, not the caller's request —
// publish here so those changes wake live feeds too (no-op when the backend is off).
onRunFlushed: (run) => {
publishChangeRecord({
runId: run.runId,
envId: run.environmentId,
tags: run.tags,
batchId: run.batchId,
updatedAtMs: run.updatedAtMs,
});
},
})
);
@@ -0,0 +1,79 @@
import { Ratelimit } from "@upstash/ratelimit";
import { type RedisWithClusterOptions } from "~/redis.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiterCore.server";
// MFA rate limiting: two sliding windows in series, both of which must pass.
// A per-minute window covers interactive retries; a cumulative daily window
// caps total attempts per pending-MFA session.
//
// Free of `env.server` so it can be tested directly against a container Redis;
// the env-derived production singletons live in `mfaRateLimiterGlobal.server.ts`.
// Production policy. Exported so tests assert against the real numbers.
export const MFA_PER_MINUTE_ATTEMPTS = 5;
export const MFA_DAILY_ATTEMPTS = 30;
export type MfaRateLimiters = {
perMinute: Pick<RateLimiter, "limit">;
daily: Pick<RateLimiter, "limit">;
};
/**
* Build the pair of MFA rate limiters. Production passes the env-derived
* Redis connection and the default policy; tests inject a container
* Redis (and may override the attempt caps to isolate one window).
*/
export function createMfaRateLimiters(options: {
redisOptions: RedisWithClusterOptions;
perMinuteAttempts?: number;
dailyAttempts?: number;
}): { perMinute: RateLimiter; daily: RateLimiter } {
const redisClient = createRedisRateLimitClient(options.redisOptions);
return {
perMinute: new RateLimiter({
redisClient,
keyPrefix: "mfa:validation",
limiter: Ratelimit.slidingWindow(options.perMinuteAttempts ?? MFA_PER_MINUTE_ATTEMPTS, "1 m"),
logSuccess: false,
logFailure: true,
}),
daily: new RateLimiter({
redisClient,
keyPrefix: "mfa:validation:daily",
limiter: Ratelimit.slidingWindow(options.dailyAttempts ?? MFA_DAILY_ATTEMPTS, "24 h"),
logSuccess: false,
logFailure: true,
}),
};
}
export class MfaRateLimitError extends Error {
public readonly retryAfter: number;
constructor(retryAfter: number) {
super(`MFA validation rate limit exceeded.`);
this.retryAfter = retryAfter;
}
}
/**
* Check whether the user can attempt MFA validation, enforcing both the
* per-minute and the daily cap. The daily cap is checked first.
* @param userId - The user ID to rate limit
* @param limiters - The limiter pair (production singletons or test-injected)
* @throws {MfaRateLimitError} If either rate limit is exceeded
*/
export async function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters): Promise<void> {
const dailyResult = await limiters.daily.limit(userId);
if (!dailyResult.success) {
const retryAfter = new Date(dailyResult.reset).getTime() - Date.now();
throw new MfaRateLimitError(retryAfter);
}
const result = await limiters.perMinute.limit(userId);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MfaRateLimitError(retryAfter);
}
}
@@ -0,0 +1,37 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import {
checkMfaRateLimit as checkMfaRateLimitWith,
createMfaRateLimiters,
type MfaRateLimiters,
} from "./mfaRateLimiter.server";
// Production singletons, wired to the env-derived rate-limit Redis.
// Kept out of `mfaRateLimiter.server.ts` so that module stays free of
// `env.server` and remains testable in isolation (see that file).
const mfaRateLimiters = singleton("mfaRateLimiters", () =>
createMfaRateLimiters({
redisOptions: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
})
);
export const mfaRateLimiter = mfaRateLimiters.perMinute;
export const mfaDailyRateLimiter = mfaRateLimiters.daily;
/**
* Production entrypoint: rate-limit an MFA validation attempt for `userId`
* against the env-configured limiter pair. Throws `MfaRateLimitError` when
* either the per-minute or the cumulative daily cap is exceeded.
*/
export function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters = mfaRateLimiters) {
return checkMfaRateLimitWith(userId, limiters);
}
export { MfaRateLimitError } from "./mfaRateLimiter.server";
@@ -0,0 +1,378 @@
import { type SecretReference, type User, type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { createRandomStringGenerator } from "@better-auth/utils/random";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { createHash } from "@better-auth/utils/hash";
import { createOTP } from "@better-auth/utils/otp";
import { base32 } from "@better-auth/utils/base32";
import { z } from "zod";
import { scheduleEmail } from "../scheduleEmail.server";
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
const SecretSchema = z.object({
secret: z.string(),
});
export class MultiFactorAuthenticationService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
include: {
mfaSecretReference: true,
},
});
if (!user) {
return {
success: false,
};
}
if (!user.mfaEnabledAt) {
return {
success: false,
};
}
if (!user.mfaSecretReference) {
return {
success: false,
};
}
// validate the TOTP code
const secretStore = getSecretStore(user.mfaSecretReference.provider);
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
if (!secretResult) {
return {
success: false,
};
}
const isValid = await this.#verifyTotpCodeOrRecoveryCode(
user,
user.mfaSecretReference,
params.totpCode,
params.recoveryCode
);
if (!isValid) {
return {
success: false,
};
}
// Delete the MFA secret
await secretStore.deleteSecret(user.mfaSecretReference.key);
// Delete the MFA backup codes
await this.#prismaClient.mfaBackupCode.deleteMany({
where: {
userId,
},
});
await this.#prismaClient.user.update({
where: { id: userId },
data: {
mfaEnabledAt: null,
mfaSecretReference: {
delete: true,
},
},
});
await scheduleEmail({
email: "mfa-disabled",
to: user.email,
userEmail: user.email,
});
return {
success: true,
};
}
public async enableTotp(userId: string) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
});
if (!user) {
throw new ServiceValidationError("User not found");
}
const secretStore = getSecretStore("DATABASE");
// Generate a new secret
const secret = generateRandomString(24);
const secretKey = `mfa:${userId}:${generateRandomString(8)}`;
// Store the secret in the SecretStore
await secretStore.setSecret(secretKey, {
secret,
});
// Update the user's secret reference to the secret store
await this.#prismaClient.user.update({
where: { id: userId },
data: {
mfaSecretReference: {
create: {
provider: "DATABASE",
key: secretKey,
},
},
},
});
// Return the secret and the recovery codes
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
const displaySecret = base32.encode(secret, {
padding: false,
});
return {
secret: displaySecret,
otpAuthUrl,
};
}
public async validateTotpSetup(userId: string, totpCode: string) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
include: {
mfaSecretReference: true,
},
});
if (!user) {
throw new ServiceValidationError("User not found");
}
if (!user.mfaSecretReference) {
throw new ServiceValidationError("User has not enabled MFA");
}
const secretStore = getSecretStore(user.mfaSecretReference.provider);
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
if (!secretResult) {
throw new ServiceValidationError("User has not enabled MFA");
}
const secret = secretResult.secret;
const otp = createOTP(secret, {
digits: 6,
period: 30,
});
const isValid = await otp.verify(totpCode);
if (!isValid) {
// Return the secret and the recovery codes
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
const displaySecret = base32.encode(secret, {
padding: false,
});
return {
success: false,
otpAuthUrl,
secret: displaySecret,
};
}
// Now that we've validated the TOTP code, we can enable MFA for the user
await this.#prismaClient.user.update({
where: { id: userId },
data: {
mfaEnabledAt: new Date(),
},
});
// Generate a new set of recovery codes
const recoveryCodes = Array.from({ length: 9 }, () => generateRandomString(16, "a-z", "0-9"));
// Delete any existing recovery codes
await this.#prismaClient.mfaBackupCode.deleteMany({
where: {
userId,
},
});
// Hash and store the recovery codes
for (const code of recoveryCodes) {
const hashedCode = await createHash("SHA-512", "hex").digest(code);
await this.#prismaClient.mfaBackupCode.create({
data: {
userId,
code: hashedCode,
},
});
}
await scheduleEmail({
email: "mfa-enabled",
to: user.email,
userEmail: user.email,
});
return {
success: true,
recoveryCodes,
};
}
async #verifyTotpCodeOrRecoveryCode(
user: User,
secretReference: SecretReference,
totpCode?: string,
recoveryCode?: string
) {
if (!totpCode && !recoveryCode) {
return false;
}
if (typeof totpCode === "string" && totpCode.length === 6) {
return this.#verifyTotpCode(user, secretReference, totpCode);
}
if (typeof recoveryCode === "string") {
return this.#verifyRecoveryCode(user, recoveryCode);
}
return false;
}
async #verifyTotpCode(user: User, secretReference: SecretReference, totpCode: string) {
const secretStore = getSecretStore(secretReference.provider);
const secretResult = await secretStore.getSecret(SecretSchema, secretReference.key);
if (!secretResult) {
return false;
}
const secret = secretResult.secret;
const isValid = await createOTP(secret, {
digits: 6,
period: 30,
}).verify(totpCode);
return isValid;
}
async #verifyRecoveryCode(user: User, recoveryCode: string) {
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
where: { userId: user.id, code: hashedCode, usedAt: null },
});
return !!backupCode;
}
// Public methods for login flow with security measures
public async verifyTotpForLogin(userId: string, totpCode: string) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
include: {
mfaSecretReference: true,
},
});
if (!user || !user.mfaEnabledAt || !user.mfaSecretReference) {
return {
success: false,
error: "Invalid authentication code",
};
}
// Check for replay attack - if this code was already used
const hashedCode = await createHash("SHA-512", "hex").digest(totpCode);
if (user.mfaLastUsedCode === hashedCode) {
return {
success: false,
error: "Invalid authentication code",
};
}
// Verify the TOTP code
const isValid = await this.#verifyTotpCode(user, user.mfaSecretReference, totpCode);
if (!isValid) {
return {
success: false,
error: "Invalid authentication code",
};
}
// Mark this code as used to prevent replay
await this.#prismaClient.user.update({
where: { id: userId },
data: {
mfaLastUsedCode: hashedCode,
},
});
return {
success: true,
};
}
public async verifyRecoveryCodeForLogin(userId: string, recoveryCode: string) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
});
if (!user || !user.mfaEnabledAt) {
return {
success: false,
error: "Invalid authentication code",
};
}
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
// Find an unused recovery code
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
where: {
userId: user.id,
code: hashedCode,
usedAt: null,
},
});
if (!backupCode) {
return {
success: false,
error: "Invalid authentication code",
};
}
// Mark this recovery code as used
await this.#prismaClient.mfaBackupCode.update({
where: { id: backupCode.id },
data: {
usedAt: new Date(),
},
});
return {
success: true,
};
}
}
@@ -0,0 +1,49 @@
import type { Session } from "@remix-run/node";
import { createCookieSessionStorage } from "@remix-run/node";
import { env } from "~/env.server";
export const onboardingSessionStorage = createCookieSessionStorage({
cookie: {
name: "__onboarding", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production", // enable this in prod only
maxAge: 60 * 60 * 24, // 1 day
},
});
export function getOnboardingSession(request: Request) {
return onboardingSessionStorage.getSession(request.headers.get("Cookie"));
}
export function commitOnboardingSession(session: Session) {
return onboardingSessionStorage.commitSession(session);
}
export async function getWorkflowDate(request: Request) {
const session = await getOnboardingSession(request);
const rawWorkflowDate = session.get("workflowDate");
if (rawWorkflowDate) {
return new Date(rawWorkflowDate);
}
}
export async function setWorkflowDate(date: Date, request: Request) {
const session = await getOnboardingSession(request);
session.set("workflowDate", date.toISOString());
return session;
}
export async function clearWorkflowDate(request: Request) {
const session = await getOnboardingSession(request);
session.unset("workflowDate");
return session;
}
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from "~/db.server";
import { requireUserId } from "./session.server";
export async function requireOrganization(request: Request, organizationSlug: string) {
const userId = await requireUserId(request);
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
deletedAt: null,
},
});
if (!organization) {
throw new Response("Organization not found", { status: 404 });
}
return { organization, userId };
}
@@ -0,0 +1,175 @@
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { hashToken } from "~/utils/tokens.server";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
// Skip the lastAccessedAt write if the existing value is already within this
// window. Eliminates per-auth UPDATE churn on a small narrow hot table; the
// settings UI reads this field at human granularity so a few-minute
// staleness is fine.
export const OAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
type CreateOrganizationAccessTokenOptions = {
name: string;
organizationId: string;
expiresAt?: Date;
};
export async function getValidOrganizationAccessTokens(organizationId: string) {
const organizationAccessTokens = await prisma.organizationAccessToken.findMany({
select: {
id: true,
name: true,
createdAt: true,
lastAccessedAt: true,
expiresAt: true,
},
where: {
organizationId,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
},
});
return organizationAccessTokens.map((oat) => ({
id: oat.id,
name: oat.name,
createdAt: oat.createdAt,
lastAccessedAt: oat.lastAccessedAt,
expiresAt: oat.expiresAt,
}));
}
export type ObfuscatedOrganizationAccessToken = Awaited<
ReturnType<typeof getValidOrganizationAccessTokens>
>[number];
export async function revokeOrganizationAccessToken(tokenId: string) {
await prisma.organizationAccessToken.update({
where: {
id: tokenId,
},
data: {
revokedAt: new Date(),
},
});
}
export type OrganizationAccessTokenAuthenticationResult = {
organizationId: string;
};
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithOrganizationAccessToken(
request: Request
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
const token = getOrganizationAccessTokenFromRequest(request);
if (!token) {
return;
}
return authenticateOrganizationAccessToken(token);
}
function getOrganizationAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const organizationAccessToken = authorization.data.replace(/^Bearer /, "");
return organizationAccessToken;
}
export async function authenticateOrganizationAccessToken(
token: string
): Promise<OrganizationAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
logger.warn(`OAT doesn't start with ${tokenPrefix}`);
return;
}
const hashedToken = hashToken(token);
const organizationAccessToken = await prisma.organizationAccessToken.findFirst({
where: {
hashedToken,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }],
},
});
if (!organizationAccessToken) {
return;
}
// Conditional updateMany — only writes if the existing lastAccessedAt is
// null or older than the throttle window. The WHERE runs inside the UPDATE
// so concurrent auths don't race into a double-write. `revokedAt: null`
// matches the findFirst guard above so a token revoked between the read
// and write doesn't get a stale lastAccessedAt update.
await prisma.organizationAccessToken.updateMany({
where: {
id: organizationAccessToken.id,
revokedAt: null,
OR: [
{ lastAccessedAt: null },
{ lastAccessedAt: { lt: new Date(Date.now() - OAT_LAST_ACCESSED_THROTTLE_MS) } },
],
},
data: {
lastAccessedAt: new Date(),
},
});
return {
organizationId: organizationAccessToken.organizationId,
};
}
export function isOrganizationAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
export async function createOrganizationAccessToken({
name,
organizationId,
expiresAt,
}: CreateOrganizationAccessTokenOptions) {
const token = createToken();
const organizationAccessToken = await prisma.organizationAccessToken.create({
data: {
name,
organizationId,
hashedToken: hashToken(token),
expiresAt,
},
});
return {
id: organizationAccessToken.id,
name,
organizationId,
token,
expiresAt: organizationAccessToken.expiresAt,
};
}
export type CreatedOrganizationAccessToken = Awaited<
ReturnType<typeof createOrganizationAccessToken>
>;
const tokenPrefix = "tr_oat_";
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
@@ -0,0 +1,467 @@
import { type PersonalAccessToken, type User } from "@trigger.dev/database";
import { customAlphabet, nanoid } from "nanoid";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { rbac } from "./rbac.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
import { isUserActorToken } from "@trigger.dev/rbac";
const tokenValueLength = 40;
//lowercase only, removed 0 and l to avoid confusion
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
// Skip the lastAccessedAt write if the existing value is already within this
// window. Eliminates per-auth UPDATE churn on a small narrow hot table; the
// /account/tokens UI reads this field at human granularity so a few-minute
// staleness is fine.
export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
// Optional: when provided, persist a TokenRole row alongside the PAT
// so PAT-authenticated requests pick up that role's permissions
// (TRI-8749). The dashboard tokens page passes a chosen system role;
// the CLI auth-code path doesn't pass one (legacy behaviour
// preserved — those PATs run with no explicit role).
roleId?: string;
};
/** Returns obfuscated access tokens that aren't revoked */
export async function getValidPersonalAccessTokens(userId: string) {
const personalAccessTokens = await prisma.personalAccessToken.findMany({
select: {
id: true,
name: true,
obfuscatedToken: true,
createdAt: true,
lastAccessedAt: true,
},
where: {
userId,
revokedAt: null,
},
});
return personalAccessTokens.map((pat) => ({
id: pat.id,
name: pat.name,
obfuscatedToken: pat.obfuscatedToken,
createdAt: pat.createdAt,
lastAccessedAt: pat.lastAccessedAt,
}));
}
export type ObfuscatedPersonalAccessToken = Awaited<
ReturnType<typeof getValidPersonalAccessTokens>
>[number];
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
select: {
personalAccessToken: true,
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, or code expired");
}
//there's no PersonalAccessToken associated with this code
if (!code.personalAccessToken) {
return {
token: null,
};
}
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
return {
token: {
token: decryptedToken,
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
},
};
}
export async function revokePersonalAccessToken(tokenId: string, userId: string) {
const result = await prisma.personalAccessToken.updateMany({
where: {
id: tokenId,
userId,
},
data: {
revokedAt: new Date(),
},
});
if (result.count === 0) {
throw new Error("PAT not found or already revoked");
}
}
export type PersonalAccessTokenAuthenticationResult = {
userId: string;
};
/**
* Smart-skip the `lastAccessedAt` write when the cached value is already
* within the throttle window. Saves one DB roundtrip per "fresh" auth.
*
* Two layers of throttling: JS-side (`Date.now() - lastAccessedAt.getTime()`)
* elides the SQL entirely when the caller already has a recent timestamp;
* SQL-side `WHERE` clause inside the `updateMany` guards against concurrent
* auths racing to a double-write when the JS check decides to fire.
*
* Called from both the legacy PAT flow (`authenticatePersonalAccessToken`)
* and the apiBuilder's RBAC-routed PAT flow (which gets `lastAccessedAt`
* from `rbac.authenticatePat`'s result). Keeps the throttle policy in one
* place rather than expecting every plugin implementer to re-implement it.
*/
export async function updateLastAccessedAtIfStale(
tokenId: string,
lastAccessedAt: Date | null
): Promise<void> {
if (lastAccessedAt && Date.now() - lastAccessedAt.getTime() <= PAT_LAST_ACCESSED_THROTTLE_MS) {
return; // fresh — no roundtrip
}
await prisma.personalAccessToken.updateMany({
where: {
id: tokenId,
revokedAt: null,
OR: [
{ lastAccessedAt: null },
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
],
},
data: { lastAccessedAt: new Date() },
});
}
const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export async function authenticateApiRequestWithPersonalAccessToken(
request: Request
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
const token = getPersonalAccessTokenFromRequest(request);
if (!token) {
return;
}
// A user-actor token authenticates as the user wherever a PAT does.
// The plugin verifies it (identity path → no org context to floor against).
if (isUserActorToken(token)) {
const result = await rbac.authenticateUserActor(request, {});
return result.ok ? { userId: result.userId } : undefined;
}
return authenticatePersonalAccessToken(token);
}
export type AdminAuthenticationResult =
| { ok: true; user: User }
| { ok: false; status: 401 | 403; message: string };
/**
* Authenticates a request via personal access token and checks the user is
* an admin. Returns a discriminated result so callers can shape the failure
* (throw a Response, wrap in neverthrow, return JSON, etc.) to fit their
* context. See `requireAdminApiRequest` for the Remix loader/action wrapper.
*/
export async function authenticateAdminRequest(
request: Request
): Promise<AdminAuthenticationResult> {
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authResult) {
return { ok: false, status: 401, message: "Invalid or Missing API key" };
}
const user = await prisma.user.findFirst({
where: { id: authResult.userId },
});
if (!user) {
return { ok: false, status: 401, message: "Invalid or Missing API key" };
}
if (!user.admin) {
return { ok: false, status: 403, message: "You must be an admin to perform this action" };
}
return { ok: true, user };
}
/**
* Remix loader/action wrapper around `authenticateAdminRequest` that throws
* a Response on failure so routes can `await` without handling the error
* branch. Uses `new Response` directly to avoid coupling this module to
* `@remix-run/server-runtime`.
*/
export async function requireAdminApiRequest(request: Request): Promise<User> {
const result = await authenticateAdminRequest(request);
if (!result.ok) {
throw new Response(JSON.stringify({ error: result.message }), {
status: result.status,
headers: { "Content-Type": "application/json" },
});
}
return result.user;
}
function getPersonalAccessTokenFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
return personalAccessToken;
}
export async function authenticatePersonalAccessToken(
token: string
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
if (!token.startsWith(tokenPrefix)) {
logger.warn(`PAT doesn't start with ${tokenPrefix}`);
return;
}
const hashedToken = hashToken(token);
const personalAccessToken = await prisma.personalAccessToken.findFirst({
where: {
hashedToken,
revokedAt: null,
},
});
if (!personalAccessToken) {
// The token may have been revoked or is entirely invalid
return;
}
await updateLastAccessedAtIfStale(personalAccessToken.id, personalAccessToken.lastAccessedAt);
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
if (decryptedToken !== token) {
logger.error(
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
);
return;
}
return {
userId: personalAccessToken.userId,
};
}
export function isPersonalAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
code: nanoid(64),
},
});
}
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
userId: string
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: {
gte: tenMinutesAgo,
},
},
});
if (!code) {
throw new Error("Invalid authorization code, code already used, or code expired");
}
const existingCliPersonalAccessToken = await prisma.personalAccessToken.findFirst({
where: {
userId,
name: "cli",
},
});
//we only allow you to have one CLI PAT at a time, so return this
if (existingCliPersonalAccessToken) {
//associate this authorization code with the existing personal access token
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: existingCliPersonalAccessToken.id,
},
});
if (existingCliPersonalAccessToken.revokedAt) {
// re-activate revoked CLI PAT so we can use it again
await prisma.personalAccessToken.update({
where: {
id: existingCliPersonalAccessToken.id,
},
data: {
revokedAt: null,
},
});
}
//we don't return the decrypted token
return {
id: existingCliPersonalAccessToken.id,
name: existingCliPersonalAccessToken.name,
userId: existingCliPersonalAccessToken.userId,
obfuscateToken: existingCliPersonalAccessToken.obfuscatedToken,
};
}
const token = await createPersonalAccessToken({
name: "cli",
userId,
});
await prisma.authorizationCode.update({
where: {
code: authorizationCode,
},
data: {
personalAccessTokenId: token.id,
},
});
return token;
}
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessToken({
name,
userId,
roleId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
// Persist the role choice via the RBAC plugin's setTokenRole. The
// plugin may store this in a separate datastore from Prisma (e.g.
// Drizzle on a different schema), so co-transactional inserts are
// awkward — we use a compensating-delete pattern instead: if
// setTokenRole fails, roll back the PAT row by deleting it. The auth
// path treats "no role" as permissive (matches the default fallback)
// so a brief orphan window between the two writes is harmless. The
// compensating delete narrows that window from "until manual cleanup"
// to "until the request returns".
//
// Skip the call entirely when no RBAC plugin is loaded — the OSS
// fallback has no TokenRole table to write to. Gating on
// `rbac.isUsingPlugin()` (rather than parsing the fallback's error
// string) keeps the OSS-vs-cloud branch explicit and decoupled from
// any specific error message.
if (roleId && (await rbac.isUsingPlugin())) {
const roleResult = await rbac.setTokenRole({
tokenId: personalAccessToken.id,
roleId,
});
if (!roleResult.ok) {
await prisma.personalAccessToken
.delete({ where: { id: personalAccessToken.id } })
.catch((err) => {
logger.error("Failed to compensating-delete PAT after TokenRole insert failed", {
patId: personalAccessToken.id,
roleResultError: roleResult.error,
deleteError: err instanceof Error ? err.message : String(err),
});
});
throw new Error(`Failed to assign role to access token: ${roleResult.error}`);
}
} else if (roleId) {
logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
patId: personalAccessToken.id,
userId,
});
}
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
export type CreatedPersonalAccessToken = Awaited<ReturnType<typeof createPersonalAccessToken>>;
const tokenPrefix = "tr_pat_";
/** Creates a PersonalAccessToken that starts with tr_pat_ */
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
return `${tokenPrefix}${obfuscated}`;
}
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
if (!encryptedData.success) {
throw new Error(
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`
);
}
const decryptedToken = decryptToken(
encryptedData.data.nonce,
encryptedData.data.ciphertext,
encryptedData.data.tag,
env.ENCRYPTION_KEY
);
return decryptedToken;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import { Redis } from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
const KEY_PREFIX = "cli-notif-ctr:";
const MAX_COUNTER = 1000;
function initializeRedis(): Redis | undefined {
const host = env.CACHE_REDIS_HOST;
if (!host) return undefined;
return new Redis({
connectionName: "platformNotificationCounter",
host,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
keyPrefix: "tr:",
enableAutoPipelining: true,
reconnectOnError: defaultReconnectOnError,
...(env.CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
});
}
const redis = singleton("platformNotificationCounter", initializeRedis);
/** Increment and return the user's CLI request counter (0-based, wraps at 1000→0). */
export async function incrementCliRequestCounter(userId: string): Promise<number> {
if (!redis) return 0;
try {
const key = `${KEY_PREFIX}${userId}`;
const value = await redis.incr(key);
if (value > MAX_COUNTER) {
await redis.set(key, "0");
return 0;
}
return value;
} catch (error) {
logger.error("Failed to increment CLI notification counter", { userId, error });
return 0;
}
}
@@ -0,0 +1,815 @@
import { z } from "zod";
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
import { prisma } from "~/db.server";
import {
type PlatformNotificationScope,
type PlatformNotificationSurface,
} from "@trigger.dev/database";
import { incrementCliRequestCounter } from "./platformNotificationCounter.server";
// --- Payload schema (spec v1) ---
const DiscoverySchema = z.object({
filePatterns: z.array(z.string().min(1)).min(1),
contentPattern: z
.string()
.max(200)
.optional()
.refine(
(val) => {
if (!val) return true;
try {
new RegExp(val);
return true;
} catch {
return false;
}
},
{ message: "contentPattern must be a valid regular expression" }
),
matchBehavior: z.enum(["show-if-found", "show-if-not-found"]),
});
// Constrain URL fields to http/https; `.url()` alone accepts other schemes
// that would be unsafe to render into an `<a href>`.
const httpUrl = z
.string()
.url()
.refine(
(v) => {
try {
const proto = new URL(v).protocol;
return proto === "http:" || proto === "https:";
} catch {
return false;
}
},
{ message: "URL must use http or https" }
);
const CardDataV1Schema = z.object({
type: z.enum(["card", "info", "warn", "error", "success", "changelog"]),
title: z.string(),
description: z.string(),
image: httpUrl.optional(),
actionLabel: z.string().optional(),
actionUrl: httpUrl.optional(),
dismissOnAction: z.boolean().optional(),
discovery: DiscoverySchema.optional(),
});
const PayloadV1Schema = z.object({
version: z.literal("1"),
data: CardDataV1Schema,
});
export type PayloadV1 = z.infer<typeof PayloadV1Schema>;
export type PlatformNotificationWithPayload = {
id: string;
friendlyId: string;
scope: string;
priority: number;
payload: PayloadV1;
isRead: boolean;
};
// --- Read: admin list with interaction stats ---
export async function getAdminNotificationsList({
page = 1,
pageSize = 20,
hideInactive = false,
}: {
page?: number;
pageSize?: number;
hideInactive?: boolean;
}) {
const where = hideInactive ? { archivedAt: null, endsAt: { gt: new Date() } } : {};
const [notifications, total] = await Promise.all([
prisma.platformNotification.findMany({
where,
orderBy: [{ createdAt: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: {
select: { interactions: true },
},
interactions: {
select: {
webappDismissedAt: true,
webappClickedAt: true,
cliDismissedAt: true,
},
},
},
}),
prisma.platformNotification.count({ where }),
]);
return {
notifications: notifications.map((n) => {
const parsed = PayloadV1Schema.safeParse(n.payload);
return {
id: n.id,
friendlyId: n.friendlyId,
title: n.title,
surface: n.surface,
scope: n.scope,
userId: n.userId,
organizationId: n.organizationId,
projectId: n.projectId,
priority: n.priority,
startsAt: n.startsAt,
endsAt: n.endsAt,
archivedAt: n.archivedAt,
createdAt: n.createdAt,
payload: n.payload,
payloadTitle: parsed.success ? parsed.data.data.title : null,
payloadType: parsed.success ? parsed.data.data.type : null,
payloadDescription: parsed.success ? parsed.data.data.description : null,
payloadActionUrl: parsed.success ? parsed.data.data.actionUrl : null,
payloadImage: parsed.success ? parsed.data.data.image : null,
payloadDismissOnAction: parsed.success
? (parsed.data.data.dismissOnAction ?? false)
: false,
payloadDiscovery: parsed.success ? (parsed.data.data.discovery ?? null) : null,
cliMaxShowCount: n.cliMaxShowCount,
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
cliShowEvery: n.cliShowEvery,
stats: {
seen: n._count.interactions,
clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length,
dismissed: n.interactions.filter(
(i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null
).length,
},
};
}),
total,
page,
pageCount: Math.ceil(total / pageSize),
};
}
// --- Read: active notifications for webapp ---
export async function getActivePlatformNotifications({
userId,
organizationId,
projectId,
}: {
userId: string;
organizationId: string;
projectId?: string;
}) {
const now = new Date();
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "WEBAPP",
archivedAt: null,
startsAt: { lte: now },
endsAt: { gt: now },
AND: [
{
OR: [
{ scope: "GLOBAL" },
{ scope: "ORGANIZATION", organizationId },
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
{ scope: "USER", userId },
],
},
],
},
include: {
interactions: {
where: { userId },
},
},
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
});
type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date };
const result: InternalNotification[] = [];
for (const n of notifications) {
const interaction = n.interactions[0] ?? null;
if (interaction?.webappDismissedAt) continue;
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) continue;
result.push({
id: n.id,
friendlyId: n.friendlyId,
scope: n.scope,
priority: n.priority,
createdAt: n.createdAt,
payload: parsed.data,
isRead: !!interaction,
});
}
result.sort(compareNotifications);
const unreadCount = result.filter((n) => !n.isRead).length;
const notifications_out: PlatformNotificationWithPayload[] = result.map(
({ createdAt: _, ...rest }) => rest
);
return { notifications: notifications_out, unreadCount };
}
function compareNotifications(
a: { priority: number; createdAt: Date },
b: { priority: number; createdAt: Date }
) {
const priorityDiff = b.priority - a.priority;
if (priorityDiff !== 0) return priorityDiff;
return b.createdAt.getTime() - a.createdAt.getTime();
}
// --- Write: upsert interaction ---
async function upsertInteraction({
notificationId,
userId,
onUpdate,
onCreate,
}: {
notificationId: string;
userId: string;
onUpdate: Record<string, unknown>;
onCreate: Record<string, unknown>;
}) {
await prisma.platformNotificationInteraction.upsert({
where: { notificationId_userId: { notificationId, userId } },
update: onUpdate,
create: {
notificationId,
userId,
firstSeenAt: new Date(),
showCount: 1,
...onCreate,
},
});
}
export async function recordNotificationSeen({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
return upsertInteraction({
notificationId,
userId,
onUpdate: { showCount: { increment: 1 } },
onCreate: {},
});
}
export async function dismissNotification({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
const now = new Date();
return upsertInteraction({
notificationId,
userId,
onUpdate: { webappDismissedAt: now },
onCreate: { webappDismissedAt: now },
});
}
export async function recordNotificationClicked({
notificationId,
userId,
}: {
notificationId: string;
userId: string;
}) {
const now = new Date();
return upsertInteraction({
notificationId,
userId,
onUpdate: { webappClickedAt: now },
onCreate: { webappClickedAt: now },
});
}
// --- Membership verification ---
export async function verifyOrgMembership({
userId,
organizationId,
projectId,
}: {
userId: string;
organizationId?: string;
projectId?: string;
}): Promise<{ organizationId?: string; projectId?: string }> {
if (!organizationId) return {};
const membership = await prisma.orgMember.findFirst({
where: { userId, organizationId },
select: { organizationId: true },
});
if (!membership) return {};
if (projectId) {
const project = await prisma.project.findFirst({
where: { id: projectId, organizationId, deletedAt: null },
select: { id: true },
});
if (!project) return { organizationId };
}
return { organizationId, projectId };
}
// --- Read: recent changelogs (for Help & Feedback) ---
export async function getRecentChangelogs({
userId,
organizationId,
projectId,
limit = 2,
}: {
userId: string;
organizationId?: string;
projectId?: string;
limit?: number;
}) {
// NOTE: Intentionally not filtering by archivedAt or endsAt.
// We want to show archived and expired changelogs in the "What's new" section
// so users can still find recent release notes.
// We DO filter by scope (to prevent user-scoped changelogs leaking to others)
// and by startsAt (to hide changelogs scheduled for the future).
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "WEBAPP",
payload: { path: ["data", "type"], equals: "changelog" },
startsAt: { lte: new Date() },
OR: [
{ scope: "GLOBAL" },
{ scope: "USER", userId },
...(organizationId ? [{ scope: "ORGANIZATION" as const, organizationId }] : []),
...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []),
],
},
orderBy: [{ createdAt: "desc" }],
take: limit,
});
return notifications
.map((n) => {
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) return null;
return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl };
})
.filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>;
}
// --- CLI: next notification for CLI surface ---
function isCliNotificationExpired(
interaction: {
userId: string;
firstSeenAt: Date;
showCount: number;
cliDismissedAt: Date | null;
} | null,
notification: {
id: string;
cliMaxDaysAfterFirstSeen: number | null;
cliMaxShowCount: number | null;
}
): boolean {
if (!interaction) return false;
let expired = false;
if (
notification.cliMaxShowCount !== null &&
interaction.showCount >= notification.cliMaxShowCount
) {
expired = true;
}
if (!expired && notification.cliMaxDaysAfterFirstSeen !== null) {
const daysSinceFirstSeen =
(Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) {
expired = true;
}
}
// For time-based expiration, persist the dismiss on the next request
// (showCount-based dismissal is handled inline at display time)
if (expired && !interaction.cliDismissedAt) {
void prisma.platformNotificationInteraction.update({
where: {
notificationId_userId: {
notificationId: notification.id,
userId: interaction.userId,
},
},
data: { cliDismissedAt: new Date() },
});
}
return expired;
}
export async function getNextCliNotification({
userId,
projectRef,
}: {
userId: string;
projectRef?: string;
}): Promise<{
id: string;
payload: PayloadV1;
showCount: number;
firstSeenAt: string;
} | null> {
const now = new Date();
// Resolve organizationId and projectId from projectRef if provided
let organizationId: string | undefined;
let projectId: string | undefined;
if (projectRef) {
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
deletedAt: null,
organization: {
deletedAt: null,
members: { some: { userId } },
},
},
select: { id: true, organizationId: true },
});
if (project) {
projectId = project.id;
organizationId = project.organizationId;
}
}
// If no projectRef or project not found, get org from membership
if (!organizationId) {
const membership = await prisma.orgMember.findFirst({
where: { userId },
select: { organizationId: true },
});
if (membership) {
organizationId = membership.organizationId;
}
}
const scopeFilter: Array<Record<string, unknown>> = [
{ scope: "GLOBAL" },
{ scope: "USER", userId },
];
if (organizationId) {
scopeFilter.push({ scope: "ORGANIZATION", organizationId });
}
if (projectId) {
scopeFilter.push({ scope: "PROJECT", projectId });
}
const notifications = await prisma.platformNotification.findMany({
where: {
surface: "CLI",
archivedAt: null,
startsAt: { lte: now },
endsAt: { gt: now },
AND: [{ OR: scopeFilter }],
},
include: {
interactions: {
where: { userId },
},
},
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
});
const sorted = [...notifications].sort(compareNotifications);
// Global per-user request counter stored in Redis, used for cliShowEvery modulo.
// This is independent of per-notification showCount so that cliMaxShowCount
// correctly tracks actual displays, not API encounters.
const requestCounter = await incrementCliRequestCounter(userId);
for (const n of sorted) {
const interaction = n.interactions[0] ?? null;
if (interaction?.cliDismissedAt) continue;
if (isCliNotificationExpired(interaction, n)) continue;
const parsed = PayloadV1Schema.safeParse(n.payload);
if (!parsed.success) continue;
// Check cliShowEvery using the global request counter
if (n.cliShowEvery !== null && requestCounter % n.cliShowEvery !== 0) {
continue;
}
// Only increment showCount when the notification will actually be displayed.
// If this display reaches cliMaxShowCount, also set cliDismissedAt now
// so it's recorded immediately rather than waiting for a future request.
const reachedMaxShows =
n.cliMaxShowCount !== null && (interaction?.showCount ?? 0) + 1 >= n.cliMaxShowCount;
const updated = await prisma.platformNotificationInteraction.upsert({
where: { notificationId_userId: { notificationId: n.id, userId } },
update: {
showCount: { increment: 1 },
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
},
create: {
notificationId: n.id,
userId,
firstSeenAt: now,
showCount: 1,
...(reachedMaxShows ? { cliDismissedAt: now } : {}),
},
});
return {
id: n.id,
payload: parsed.data,
showCount: updated.showCount,
firstSeenAt: updated.firstSeenAt.toISOString(),
};
}
return null;
}
// --- Create: admin endpoint support ---
const SCOPE_REQUIRED_FK: Record<string, "userId" | "organizationId" | "projectId"> = {
USER: "userId",
ORGANIZATION: "organizationId",
PROJECT: "projectId",
};
const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const;
const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount", "cliShowEvery"] as const;
const NotificationBaseFields = {
title: z.string().min(1),
payload: PayloadV1Schema,
surface: z.enum(["WEBAPP", "CLI"]),
scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]),
userId: z.string().optional(),
organizationId: z.string().optional(),
projectId: z.string().optional(),
endsAt: z
.string()
.datetime()
.transform((s) => new Date(s)),
priority: z.number().int().default(0),
cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(),
cliMaxShowCount: z.number().int().positive().optional(),
cliShowEvery: z.number().int().min(2).optional(),
};
export const CreatePlatformNotificationSchema = z
.object({
...NotificationBaseFields,
startsAt: z
.string()
.datetime()
.transform((s) => new Date(s))
.optional(),
})
.superRefine((data, ctx) => {
validateScopeForeignKeys(data, ctx);
validateSurfaceFields(data, ctx);
validatePayloadTypeForSurface(data, ctx);
validateStartsAt(data, ctx);
validateEndsAt(data, ctx);
});
function validateScopeForeignKeys(
data: { scope: string; userId?: string; organizationId?: string; projectId?: string },
ctx: z.RefinementCtx
) {
const requiredFk = SCOPE_REQUIRED_FK[data.scope];
if (requiredFk && !data[requiredFk]) {
ctx.addIssue({
code: "custom",
message: `${requiredFk} is required when scope is ${data.scope}`,
path: [requiredFk],
});
}
const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk);
for (const fk of forbiddenFks) {
if (data[fk]) {
ctx.addIssue({
code: "custom",
message: `${fk} must not be set when scope is ${data.scope}`,
path: [fk],
});
}
}
}
function validateSurfaceFields(
data: {
surface: string;
cliMaxDaysAfterFirstSeen?: number;
cliMaxShowCount?: number;
cliShowEvery?: number;
},
ctx: z.RefinementCtx
) {
if (data.surface !== "WEBAPP") return;
for (const field of CLI_ONLY_FIELDS) {
if (data[field] !== undefined) {
ctx.addIssue({
code: "custom",
message: `${field} is not allowed for WEBAPP surface`,
path: [field],
});
}
}
}
function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) {
if (!data.startsAt) return;
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
if (data.startsAt < oneHourAgo) {
ctx.addIssue({
code: "custom",
message: "startsAt must be within the last hour or in the future",
path: ["startsAt"],
});
}
}
const CLI_TYPES = new Set(["info", "warn", "error", "success"]);
const WEBAPP_TYPES = new Set(["card", "changelog"]);
function validatePayloadTypeForSurface(
data: { surface: string; payload: PayloadV1 },
ctx: z.RefinementCtx
) {
const allowedTypes = data.surface === "CLI" ? CLI_TYPES : WEBAPP_TYPES;
if (!allowedTypes.has(data.payload.data.type)) {
ctx.addIssue({
code: "custom",
message: `payload.data.type "${data.payload.data.type}" is not allowed for ${data.surface} surface`,
path: ["payload", "data", "type"],
});
}
}
function validateEndsAt(data: { startsAt?: Date; endsAt: Date }, ctx: z.RefinementCtx) {
const effectiveStart = data.startsAt ?? new Date();
if (data.endsAt <= effectiveStart) {
ctx.addIssue({
code: "custom",
message: "endsAt must be after startsAt",
path: ["endsAt"],
});
}
}
export type CreatePlatformNotificationInput = z.input<typeof CreatePlatformNotificationSchema>;
// --- Update: admin endpoint support ---
export const UpdatePlatformNotificationSchema = z
.object({
...NotificationBaseFields,
id: z.string().min(1),
startsAt: z
.string()
.datetime()
.transform((s) => new Date(s)),
})
.superRefine((data, ctx) => {
validateScopeForeignKeys(data, ctx);
validateSurfaceFields(data, ctx);
validatePayloadTypeForSurface(data, ctx);
// NOTE: No validateStartsAt — existing notifications may have past startsAt
validateEndsAt(data, ctx);
});
type CreateError = { type: "validation"; issues: z.ZodIssue[] } | { type: "db"; message: string };
export function createPlatformNotification(
input: CreatePlatformNotificationInput
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
const parseResult = CreatePlatformNotificationSchema.safeParse(input);
if (!parseResult.success) {
return errAsync({ type: "validation", issues: parseResult.error.issues });
}
const data = parseResult.data;
return fromPromise(
prisma.platformNotification.create({
data: {
title: data.title,
payload: data.payload,
surface: data.surface as PlatformNotificationSurface,
scope: data.scope as PlatformNotificationScope,
userId: data.userId,
organizationId: data.organizationId,
projectId: data.projectId,
startsAt: data.startsAt ?? new Date(),
endsAt: data.endsAt,
priority: data.priority,
cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen,
cliMaxShowCount: data.cliMaxShowCount,
cliShowEvery: data.cliShowEvery,
},
select: { id: true, friendlyId: true },
}),
(e): CreateError => ({
type: "db",
message: e instanceof Error ? e.message : String(e),
})
);
}
export function updatePlatformNotification(
input: z.input<typeof UpdatePlatformNotificationSchema>
): ResultAsync<{ id: string; friendlyId: string }, CreateError> {
const parseResult = UpdatePlatformNotificationSchema.safeParse(input);
if (!parseResult.success) {
return errAsync({ type: "validation", issues: parseResult.error.issues });
}
const data = parseResult.data;
return fromPromise(
prisma.platformNotification.update({
where: { id: data.id },
data: {
title: data.title,
payload: data.payload,
surface: data.surface as PlatformNotificationSurface,
scope: data.scope as PlatformNotificationScope,
userId: data.scope === "USER" ? data.userId : null,
organizationId: data.scope === "ORGANIZATION" ? data.organizationId : null,
projectId: data.scope === "PROJECT" ? data.projectId : null,
startsAt: data.startsAt,
endsAt: data.endsAt,
priority: data.priority,
cliMaxDaysAfterFirstSeen:
data.surface === "CLI" ? (data.cliMaxDaysAfterFirstSeen ?? null) : null,
cliMaxShowCount: data.surface === "CLI" ? (data.cliMaxShowCount ?? null) : null,
cliShowEvery: data.surface === "CLI" ? (data.cliShowEvery ?? null) : null,
},
select: { id: true, friendlyId: true },
}),
(e): CreateError => ({
type: "db",
message: e instanceof Error ? e.message : String(e),
})
);
}
export async function deletePlatformNotification(id: string): Promise<void> {
await prisma.platformNotification.delete({ where: { id } });
}
export async function publishNowPlatformNotification(id: string): Promise<void> {
await prisma.platformNotification.update({
where: { id },
data: { startsAt: new Date() },
});
}
export async function archivePlatformNotification(id: string): Promise<void> {
await prisma.platformNotification.update({
where: { id },
data: { archivedAt: new Date() },
});
}
@@ -0,0 +1,17 @@
import type { User } from "~/models/user.server";
import { telemetry } from "./telemetry.server";
export async function postAuthentication({
user,
loginMethod,
isNewUser,
}: {
user: User;
loginMethod: User["authenticationMethod"];
isNewUser: boolean;
}) {
telemetry.user.identify({
user,
isNewUser,
});
}
@@ -0,0 +1,56 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { env } from "~/env.server";
export const uiPreferencesStorage = createCookieSessionStorage({
cookie: {
name: "__ui_prefs",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
},
});
export function getUiPreferencesSession(request: Request) {
return uiPreferencesStorage.getSession(request.headers.get("Cookie"));
}
export async function getUsefulLinksPreference(request: Request): Promise<boolean | undefined> {
const session = await getUiPreferencesSession(request);
return session.get("showUsefulLinks");
}
export async function setUsefulLinksPreference(show: boolean, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("showUsefulLinks", show);
return session;
}
export async function getRootOnlyFilterPreference(request: Request): Promise<boolean> {
const session = await getUiPreferencesSession(request);
const rootOnly = session.get("rootOnly");
if (rootOnly === undefined) {
return false;
}
return rootOnly;
}
export async function setRootOnlyFilterPreference(rootOnly: boolean, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("rootOnly", rootOnly);
return session;
}
export async function getTimezonePreference(request: Request): Promise<string> {
const session = await getUiPreferencesSession(request);
const timezone = session.get("timezone");
return typeof timezone === "string" ? timezone : "UTC";
}
export async function setTimezonePreference(timezone: string, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("timezone", timezone);
return session;
}
@@ -0,0 +1,35 @@
import type { Organization, Project } from "@trigger.dev/database";
import { createEnvironment } from "~/models/organization.server";
import { getCurrentPlan, isCloud } from "~/services/platform.v3.server";
// Extracted from platform.v3.server.ts to break a circular import:
// platform.v3.server ↔ models/organization.server (via createEnvironment).
// The cycle caused the bundled __esm wrappers to re-enter and short-circuit
// the platform.v3.server init, leaving `defaultMachine` and `machines`
// undefined in `singleton("machinePresets", ...)` — the boot crash at
// `allMachines()` traced to TRI-8731.
export async function projectCreated(
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
project: Project
) {
if (!isCloud()) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
} else {
const plan = await getCurrentPlan(organization.id);
if (plan?.v3Subscription?.plan?.limits?.hasStagingEnvironment) {
await createEnvironment({ organization, project, type: "STAGING" });
await createEnvironment({
organization,
project,
type: "PREVIEW",
isBranchableEnvironment: true,
});
}
}
}
@@ -0,0 +1,329 @@
import { type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { DeleteProjectService } from "~/services/deleteProject.server";
import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github";
import { checkGitHubBranchExists } from "~/services/gitHub.server";
import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow";
import { type BuildSettings } from "~/v3/buildSettings";
export class ProjectSettingsService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
renameProject(projectId: string, newName: string) {
return fromPromise(
this.#prismaClient.project.update({
where: {
id: projectId,
},
data: {
name: newName,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
}
deleteProject(projectId: string, userId: string) {
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
type: "other" as const,
cause: error,
}));
}
connectGitHubRepo(
projectId: string,
organizationId: string,
repositoryId: string,
installationId: string
) {
const getRepository = () =>
fromPromise(
this.#prismaClient.githubRepository.findFirst({
where: {
id: repositoryId,
installationId,
installation: {
organizationId: organizationId,
},
},
select: {
id: true,
name: true,
defaultBranch: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((repository) => {
if (!repository) {
return errAsync({ type: "gh_repository_not_found" as const });
}
return okAsync(repository);
});
const findExistingConnection = () =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId: projectId,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
const createConnectedRepo = (defaultBranch: string, previewDeploymentsEnabled: boolean) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.create({
data: {
projectId: projectId,
repositoryId: repositoryId,
branchTracking: {
prod: { branch: defaultBranch },
staging: {},
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return ResultAsync.combine([
getRepository(),
findExistingConnection(),
this.isPreviewEnvironmentEnabled(projectId),
]).andThen(([repository, existingConnection, previewEnvironmentEnabled]) => {
if (existingConnection) {
return errAsync({ type: "project_already_has_connected_repository" as const });
}
return createConnectedRepo(repository.defaultBranch, previewEnvironmentEnabled);
});
}
disconnectGitHubRepo(projectId: string) {
return fromPromise(
this.#prismaClient.connectedGithubRepository.delete({
where: {
projectId: projectId,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
}
updateGitSettings(
projectId: string,
productionBranch?: string,
stagingBranch?: string,
previewDeploymentsEnabled?: boolean
) {
const getExistingConnectedRepo = () =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId: projectId,
},
include: {
repository: {
include: {
installation: true,
},
},
},
}),
(error) => ({ type: "other" as const, cause: error })
)
.andThen((connectedRepo) => {
if (!connectedRepo) {
return errAsync({ type: "connected_gh_repository_not_found" as const });
}
return okAsync(connectedRepo);
})
.map((connectedRepo) => {
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedRepo.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedRepo,
branchTracking,
};
});
const validateProductionBranch = ({
installationId,
fullRepoName,
oldProductionBranch,
}: {
installationId: number;
fullRepoName: string;
oldProductionBranch?: string;
}) => {
if (productionBranch && oldProductionBranch !== productionBranch) {
return checkGitHubBranchExists(installationId, fullRepoName, productionBranch).andThen(
(exists) => {
if (!exists) {
return errAsync({ type: "production_tracking_branch_not_found" as const });
}
return okAsync(productionBranch);
}
);
}
return okAsync(productionBranch);
};
const validateStagingBranch = ({
installationId,
fullRepoName,
oldStagingBranch,
}: {
installationId: number;
fullRepoName: string;
oldStagingBranch?: string;
}) => {
if (stagingBranch && oldStagingBranch !== stagingBranch) {
return checkGitHubBranchExists(installationId, fullRepoName, stagingBranch).andThen(
(exists) => {
if (!exists) {
return errAsync({ type: "staging_tracking_branch_not_found" as const });
}
return okAsync(stagingBranch);
}
);
}
return okAsync(stagingBranch);
};
const updateConnectedRepo = (data: {
productionBranch: string | undefined;
stagingBranch: string | undefined;
previewDeploymentsEnabled: boolean | undefined;
}) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.update({
where: {
projectId: projectId,
},
data: {
branchTracking: {
prod: data.productionBranch ? { branch: data.productionBranch } : {},
staging: data.stagingBranch ? { branch: data.stagingBranch } : {},
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled: data.previewDeploymentsEnabled,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return getExistingConnectedRepo()
.andThen((connectedRepo) => {
const installationId = Number(connectedRepo.repository.installation.appInstallationId);
return ResultAsync.combine([
validateProductionBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldProductionBranch: connectedRepo.branchTracking?.prod?.branch,
}),
validateStagingBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldStagingBranch: connectedRepo.branchTracking?.staging?.branch,
}),
this.isPreviewEnvironmentEnabled(projectId),
]);
})
.map(([productionBranch, stagingBranch, previewEnvironmentEnabled]) => ({
productionBranch,
stagingBranch,
previewDeploymentsEnabled: previewDeploymentsEnabled && previewEnvironmentEnabled,
}))
.andThen(updateConnectedRepo);
}
updateBuildSettings(projectId: string, buildSettings: BuildSettings) {
return fromPromise(
this.#prismaClient.project.update({
where: {
id: projectId,
},
data: {
buildSettings: buildSettings,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
}
verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) {
const findProject = () =>
fromPromise(
this.#prismaClient.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
organizationId: true,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return findProject().andThen((project) => {
if (!project) {
return errAsync({ type: "user_not_in_project" as const });
}
return okAsync({
projectId: project.id,
organizationId: project.organizationId,
});
});
}
private isPreviewEnvironmentEnabled(projectId: string) {
return fromPromise(
this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
}
}
@@ -0,0 +1,172 @@
import { type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { BuildSettingsSchema } from "~/v3/buildSettings";
export class ProjectSettingsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
const getProject = () =>
fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({
type: "other" as const,
cause: error,
}))
.andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
return ok(project);
})
.map((project) => {
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
return { ...project, buildSettings };
});
if (!githubAppEnabled) {
return getProject().map(({ buildSettings }) => ({
gitHubApp: {
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
},
buildSettings,
}));
}
const findConnectedGithubRepository = (projectId: string) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = (organizationId: string) =>
fromPromise(
this.#prismaClient.githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
// Most installations will only have a couple of repos so loading them here should be fine.
// However, there might be outlier organizations so it's best to expose the installation repos
// via a resource endpoint and filter on user input.
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = (projectId: string) =>
fromPromise(
this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return getProject().andThen((project) =>
ResultAsync.combine([
isPreviewEnvironmentEnabled(project.id),
findConnectedGithubRepository(project.id),
listGithubAppInstallations(project.organizationId),
]).map(
([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
gitHubApp: {
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
},
buildSettings: project.buildSettings,
})
)
);
}
}
@@ -0,0 +1,26 @@
import { createCookie } from "@remix-run/node";
import { env } from "~/env.server";
// Carries a promo code from the landing page through signup to first-org
// creation. httpOnly + sameSite=lax so it survives the OAuth round-trip,
// matching the existing redirect-to cookie.
export const promoCodeCookie = createCookie("promo-code", {
maxAge: 60 * 60, // 1 hour — enough to complete signup
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
});
export async function setPromoCodeCookie(code: string): Promise<string> {
return await promoCodeCookie.serialize(code);
}
export async function getPromoCodeFromCookie(request: Request): Promise<string | null> {
const value = await promoCodeCookie.parse(request.headers.get("Cookie"));
return typeof value === "string" && value.length > 0 ? value : null;
}
export async function clearPromoCodeCookie(): Promise<string> {
return await promoCodeCookie.serialize("", { maxAge: 0 });
}
@@ -0,0 +1,28 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { RedisConcurrencyLimiter } from "./redisConcurrencyLimiter.server";
function initializeQueryConcurrencyLimiter() {
return new RedisConcurrencyLimiter({
keyPrefix: "query:concurrency",
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
}
export const queryConcurrencyLimiter = singleton(
"queryConcurrencyLimiter",
initializeQueryConcurrencyLimiter
);
/** Default per-org concurrency limit from environment */
export const DEFAULT_ORG_CONCURRENCY_LIMIT = env.QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT;
/** Global concurrency limit from environment */
export const GLOBAL_CONCURRENCY_LIMIT = env.QUERY_GLOBAL_CONCURRENCY_LIMIT;
@@ -0,0 +1,382 @@
import {
executeTSQL,
QueryError,
type ClickHouseSettings,
type ExecuteTSQLOptions,
type FieldMappings,
type TSQLQueryResult,
} from "@internal/clickhouse";
import type { CustomerQuerySource } from "@trigger.dev/database";
import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { clickhouseFactory } from "./clickhouse/clickhouseFactoryInstance.server";
import {
queryConcurrencyLimiter,
DEFAULT_ORG_CONCURRENCY_LIMIT,
GLOBAL_CONCURRENCY_LIMIT,
} from "./queryConcurrencyLimiter.server";
import { getLimit } from "./platform.v3.server";
import { timeFilters, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import parse from "parse-duration";
import { querySchemas, QueryScopeSchema, type QueryScope } from "~/v3/querySchemas";
export { QueryScopeSchema };
export type { TableSchema, TSQLQueryResult, QueryScope };
const scopeToEnum = {
organization: "ORGANIZATION",
project: "PROJECT",
environment: "ENVIRONMENT",
} as const;
/**
* Default ClickHouse settings for query protection
* Based on PostHog's HogQL settings to prevent expensive queries
*/
function getDefaultClickhouseSettings(): ClickHouseSettings {
return {
// Query execution limits
max_execution_time: env.QUERY_CLICKHOUSE_MAX_EXECUTION_TIME,
timeout_overflow_mode: "throw",
max_memory_usage: String(env.QUERY_CLICKHOUSE_MAX_MEMORY_USAGE),
// AST complexity limits to prevent extremely complex queries
max_ast_elements: String(env.QUERY_CLICKHOUSE_MAX_AST_ELEMENTS),
max_expanded_ast_elements: String(env.QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS),
// Memory management for GROUP BY operations
max_bytes_before_external_group_by: String(
env.QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY
),
// Safety settings
format_csv_allow_double_quotes: 0,
readonly: "1", // Ensure queries are read-only
};
}
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
ExecuteTSQLOptions<TOut>,
"tableSchema" | "fieldMappings" | "enforcedWhereClause" | "whereClauseFallback" | "schema"
> & {
organizationId: string;
projectId: string;
environmentId: string;
/** The scope of the query - determines tenant isolation */
scope: QueryScope;
period?: string | null;
from?: string | null;
to?: string | null;
/** Filter to specific task identifiers */
taskIdentifiers?: string[];
/** Filter to specific queues */
queues?: string[];
/** Filter to specific response models */
responseModels?: string[];
/** Filter to specific prompt slugs */
promptSlugs?: string[];
/** Filter to specific prompt versions */
promptVersions?: number[];
/** Filter to specific operations (e.g. ai.generateText.doGenerate) */
operations?: string[];
/** Filter to specific providers (e.g. openai.responses) */
providers?: string[];
/** History options for saving query to billing/audit */
history?: {
/** Where the query originated from */
source: CustomerQuerySource;
/** User ID (optional, null for API calls) */
userId?: string | null;
/** Skip saving to history (e.g., when impersonating) */
skip?: boolean;
};
/** Custom per-org concurrency limit (overrides default) */
customOrgConcurrencyLimit?: number;
};
/**
* Extended result type that includes the optional queryId when saved to history
*/
export type ExecuteQueryResult<T> =
| {
success: true;
result: T;
queryId: string | null;
periodClipped: number | null;
maxQueryPeriod: number;
timeRange: { from: Date; to: Date };
}
| { success: false; error: Error };
export async function getDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 7;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
/**
* Execute a TSQL query against ClickHouse with tenant isolation
* Handles building tenant options, field mappings, and optionally saves to history
* Returns [error, result, queryId] where queryId is the CustomerQuery ID if saved to history
*/
export async function executeQuery<TOut extends z.ZodSchema>(
options: ExecuteQueryOptions<TOut>
): Promise<ExecuteQueryResult<Exclude<TSQLQueryResult<z.output<TOut>>[1], null>>> {
const {
period,
from,
to,
scope,
organizationId,
projectId,
environmentId,
taskIdentifiers,
queues,
responseModels,
promptSlugs,
promptVersions,
operations,
providers,
history,
customOrgConcurrencyLimit,
...baseOptions
} = options;
// Generate unique request ID for concurrency tracking
const requestId = crypto.randomUUID();
const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT;
// Acquire concurrency slot
const acquireResult = await queryConcurrencyLimiter.acquire({
key: projectId,
requestId,
keyLimit: orgLimit,
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
});
if (!acquireResult.success) {
const errorMessage =
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this project. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
return { success: false, error: new QueryError(errorMessage, { query: options.query }) };
}
// Detect which table the query targets to determine the time column
// Each table schema declares its primary time column via timeConstraint
const matchedSchema = querySchemas.find((s) =>
new RegExp(`\\bFROM\\s+${s.name}\\b`, "i").test(options.query)
);
const timeColumn = matchedSchema?.timeConstraint ?? "triggered_at";
// Build time filter fallback for the table's time column
const defaultPeriod = await getDefaultPeriod(organizationId);
const timeFilter = timeFilters({
period: period ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
defaultPeriod,
});
// Calculate the effective "from" date the user is requesting (for period clipping check)
// This is null only when the user specifies just a "to" date (rare case)
let requestedFromDate: Date | null = null;
if (timeFilter.from) {
requestedFromDate = new Date(timeFilter.from);
} else if (!timeFilter.to) {
// Period specified (or default) - calculate from now
const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000;
requestedFromDate = new Date(Date.now() - periodMs);
}
// Build the fallback WHERE condition based on what the user specified
let timeFallback: WhereClauseCondition;
if (timeFilter.from && timeFilter.to) {
timeFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
} else if (timeFilter.from) {
timeFallback = { op: "gte", value: timeFilter.from };
} else if (timeFilter.to) {
timeFallback = { op: "lte", value: timeFilter.to };
} else {
timeFallback = { op: "gte", value: requestedFromDate! };
}
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000);
// Check if the requested time period exceeds the plan limit
const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate;
// Force tenant isolation and time period limits
// Global tables (no tenantColumns) skip tenant isolation — they contain anonymized cross-tenant data
const isGlobalTable = matchedSchema != null && !matchedSchema.tenantColumns;
const enforcedWhereClause = {
...(isGlobalTable
? {}
: {
organization_id: { op: "eq", value: organizationId },
project_id:
scope === "project" || scope === "environment"
? { op: "eq", value: projectId }
: undefined,
environment_id: scope === "environment" ? { op: "eq", value: environmentId } : undefined,
}),
[timeColumn]: { op: "gte", value: maxQueryPeriodDate },
// Optional filters for tasks and queues
task_identifier:
taskIdentifiers && taskIdentifiers.length > 0
? { op: "in", values: taskIdentifiers }
: undefined,
queue: queues && queues.length > 0 ? { op: "in", values: queues } : undefined,
response_model:
responseModels && responseModels.length > 0
? { op: "in", values: responseModels }
: undefined,
prompt_slug:
promptSlugs && promptSlugs.length > 0 ? { op: "in", values: promptSlugs } : undefined,
prompt_version:
promptVersions && promptVersions.length > 0
? { op: "in", values: promptVersions }
: undefined,
operation_id:
operations && operations.length > 0 ? { op: "in", values: operations } : undefined,
gen_ai_system: providers && providers.length > 0 ? { op: "in", values: providers } : undefined,
} satisfies Record<string, WhereClauseCondition | undefined>;
// Compute the effective time range for timeBucket() interval calculation
const timeRange = timeFilterFromTo({
period: period ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
defaultPeriod,
});
try {
// Build field mappings for project_ref → project_id and environment_id → slug translation
const projects = await prisma.project.findMany({
where: { organizationId },
select: { id: true, externalRef: true },
});
const environments = await prisma.runtimeEnvironment.findMany({
where: { project: { organizationId } },
select: { id: true, slug: true },
});
const fieldMappings: FieldMappings = {
project: Object.fromEntries(projects.map((p) => [p.id, p.externalRef])),
environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])),
};
const queryClickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"query"
);
const result = await executeTSQL(queryClickhouse.reader, {
...baseOptions,
schema: z.record(z.any()),
tableSchema: querySchemas,
transformValues: true,
enforcedWhereClause,
fieldMappings,
whereClauseFallback: {
[timeColumn]: timeFallback,
},
timeRange,
clickhouseSettings: {
...getDefaultClickhouseSettings(),
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
},
querySettings: {
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
...baseOptions.querySettings, // Allow caller overrides if needed
},
});
// If query failed, return early with no queryId
if (result[0] !== null) {
return { success: false, error: result[0] };
}
let queryId: string | null = null;
// If query succeeded and history options provided, save to history
// Skip history for EXPLAIN queries (admin debugging) and when explicitly skipped (e.g., impersonating)
if (history && !history.skip && !baseOptions.explain) {
// Check if this query is the same as the last one saved (avoid duplicate history entries)
const lastQuery = await prisma.customerQuery.findFirst({
where: {
organizationId,
source: history.source,
userId: history.userId ?? null,
},
orderBy: { createdAt: "desc" },
select: {
id: true,
query: true,
scope: true,
filterPeriod: true,
filterFrom: true,
filterTo: true,
},
});
// Save the effective period used for the query (timeFilters() handles defaults)
// Only save period if no custom from/to range was specified
const historyTimeFilter = {
period: timeFilter.from || timeFilter.to ? undefined : timeFilter.period,
from: timeFilter.from,
to: timeFilter.to,
};
const isDuplicate =
lastQuery &&
lastQuery.query === options.query &&
lastQuery.scope === scopeToEnum[scope] &&
lastQuery.filterPeriod === (timeFilter?.period ?? null) &&
lastQuery.filterFrom?.getTime() === (timeFilter?.from?.getTime() ?? undefined) &&
lastQuery.filterTo?.getTime() === (timeFilter?.to?.getTime() ?? undefined);
if (isDuplicate && lastQuery) {
// Return the existing query's ID for duplicate queries
queryId = lastQuery.id;
} else {
const created = await prisma.customerQuery.create({
data: {
query: options.query,
scope: scopeToEnum[scope],
stats: { ...result[1].stats },
source: history.source,
organizationId,
projectId: scope === "project" || scope === "environment" ? projectId : null,
environmentId: scope === "environment" ? environmentId : null,
userId: history.userId ?? null,
filterPeriod: historyTimeFilter?.period ?? null,
filterFrom: historyTimeFilter?.from ?? null,
filterTo: historyTimeFilter?.to ?? null,
},
});
queryId = created.id;
}
}
return {
success: true,
result: result[1],
queryId,
periodClipped: periodClipped ? maxQueryPeriod : null,
maxQueryPeriod,
timeRange,
};
} finally {
// Always release the concurrency slot
await queryConcurrencyLimiter.release({
key: projectId,
requestId,
});
}
}
@@ -0,0 +1,40 @@
import { env } from "~/env.server";
import type { RedisWithClusterOptions } from "~/redis.server";
import {
RateLimiter as CoreRateLimiter,
type Limiter,
type RateLimiterRedisClient,
} from "./rateLimiterCore.server";
export {
createRedisRateLimitClient,
type Duration,
type Limiter,
type RateLimitResponse,
type RateLimiterRedisClient,
} from "./rateLimiterCore.server";
type Options = {
redis?: RedisWithClusterOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
logSuccess?: boolean;
logFailure?: boolean;
};
export class RateLimiter extends CoreRateLimiter {
constructor(options: Options) {
super({
...options,
redis: options.redis ?? {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
}
}
@@ -0,0 +1,100 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "./logger.server";
type Options = {
redis?: RedisWithClusterOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
logSuccess?: boolean;
logFailure?: boolean;
};
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
export class RateLimiter {
#ratelimit: Ratelimit;
constructor(private readonly options: Options) {
const { redis, redisClient, keyPrefix, limiter } = options;
const prefix = `ratelimit:${keyPrefix}`;
const resolvedRedisClient =
redisClient ?? (redis ? createRedisRateLimitClient(redis) : undefined);
if (!resolvedRedisClient) {
throw new Error("RateLimiter requires either redis or redisClient options");
}
this.#ratelimit = new Ratelimit({
redis: resolvedRedisClient,
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix,
});
}
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
const result = this.#ratelimit.limit(identifier, { rate });
const { success, limit, reset, remaining } = await result;
if (success && this.options.logSuccess) {
logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, {
limit,
reset,
remaining,
identifier,
});
}
//log these by default
if (!success && this.options.logFailure !== false) {
logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, {
limit,
reset,
remaining,
identifier,
});
}
return result;
}
}
export function createRedisRateLimitClient(
redisOptions: RedisWithClusterOptions
): RateLimiterRedisClient {
const redis = createRedisClient("trigger:rateLimiter", redisOptions);
return {
sadd: async <TData>(key: string, ...members: TData[]): Promise<number> => {
return redis.sadd(key, members as (string | number | Buffer)[]);
},
hset: <TValue>(
key: string,
obj: {
[key: string]: TValue;
}
): Promise<number> => {
return redis.hset(key, obj);
},
eval: <TArgs extends unknown[], TData = unknown>(
...args: [script: string, keys: string[], args: TArgs]
): Promise<TData> => {
const script = args[0];
const keys = args[1];
const argsArray = args[2];
return redis.eval(
script,
keys.length,
...keys,
...(argsArray as (string | Buffer | number)[])
) as Promise<TData>;
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { $replica, prisma } from "~/db.server";
import type { PrismaClient } from "@trigger.dev/database";
import plugin from "@trigger.dev/rbac";
import { env } from "~/env.server";
// plugin.create() is synchronous — returns a lazy controller that resolves
// any installed RBAC plugin on first call. Top-level await is not used
// because CJS output format does not support it.
//
// Auth-path reads run on every request — pass the replica explicitly so
// they don't pile up on the primary. Writes (role mutations) still go
// through the primary. Same separation findEnvironmentByApiKey used
// before this PR moved bearer auth into the RBAC plugin.
//
// Session-cookie userId resolution lives at the call site (see
// dashboardBuilder.server.ts), not here. Statically importing
// `~/services/session.server` from this module dragged the entire
// remix-auth pipeline (auth.server → emailAuth/gitHubAuth/googleAuth,
// each validating their secret at module load) into anything that
// transitively imported `rbac` — including PAT auth callers that have
// no session-cookie path at all. Passing userId through the
// `authenticateSession` context decouples the plugin host from the
// host's session implementation.
export const rbac = plugin.create(
// $replica is structurally a PrismaClient minus `$transaction` — the
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
{ primary: prisma, replica: $replica as PrismaClient },
// SESSION_SECRET signs delegated user-actor tokens; the plugin verifies
// them with it in authenticateUserActor.
{
forceFallback: env.RBAC_FORCE_FALLBACK,
userActorSecret: env.SESSION_SECRET,
// A plugin that owns its own database client gets the same
// writer/replica topology the webapp's Prisma clients use (see
// getClient/getReplicaClient in db.server.ts): control-plane URLs win,
// and with no replica configured reads share the writer.
database: {
writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL,
readerUrl: env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL,
writerConnectionLimit: env.RBAC_DATABASE_WRITER_CONNECTION_LIMIT,
readerConnectionLimit: env.RBAC_DATABASE_READER_CONNECTION_LIMIT,
},
}
);
@@ -0,0 +1,50 @@
/**
* Tiny in-process bounded TTL cache shared by the realtime feeds: entries expire after `ttlMs` (evicted on read),
* and at-capacity writes sweep expired entries then drop the oldest. A stored `undefined` is indistinguishable from a miss (use `null` for absence).
*/
export class BoundedTtlCache<V> {
readonly #entries = new Map<string, { value: V; expiresAt: number }>();
constructor(
private readonly ttlMs: number,
private readonly maxEntries: number
) {}
get(key: string): V | undefined {
const entry = this.#entries.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt > Date.now()) {
return entry.value;
}
// Evict on read so expired entries don't linger until the next at-capacity
// sweep — important for read-heavy / low-churn caches (per-handle working sets).
this.#entries.delete(key);
return undefined;
}
set(key: string, value: V): void {
// Only run capacity eviction when inserting a NEW key — updating an existing key
// doesn't grow the map, so it must never drop an unrelated live entry.
if (!this.#entries.has(key) && this.#entries.size >= this.maxEntries) {
const now = Date.now();
for (const [key, entry] of this.#entries) {
if (entry.expiresAt <= now) {
this.#entries.delete(key);
}
}
if (this.#entries.size >= this.maxEntries) {
const oldest = this.#entries.keys().next().value;
if (oldest !== undefined) {
this.#entries.delete(oldest);
}
}
}
this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
get size(): number {
return this.#entries.size;
}
}
@@ -0,0 +1,34 @@
import { env } from "~/env.server";
/**
* Canonical storage URI for a session's chat.agent snapshot. Stamped on
* `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns
* resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL`
* changes later.
*/
export function chatSnapshotStoragePathForSession(friendlyId: string): string {
const path = `sessions/${friendlyId}/snapshot.json`;
const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL;
return protocol ? `${protocol}://${path}` : path;
}
/**
* Resolve the storage key/URI a session's chat snapshot is written to and read
* from. Single source of truth shared by every reader/writer so they all hit
* the same object store:
* - the SDK write + boot read (via the `snapshot-url` presign route), and
* - the dashboard `SessionPresenter` (Agent/Session view).
*
* Prefers `chatSnapshotStoragePath` stamped at row creation (already
* protocol-qualified, e.g. `s3://sessions/{id}/snapshot.json`), falling back to
* recomputing it for sessions created before the column existed. Using a bare,
* unqualified key here is the bug this guards against: the object store applies
* `OBJECT_STORE_DEFAULT_PROTOCOL` to unprefixed keys on PUT but not on GET, so a
* bare key can write to one store and read from another.
*/
export function chatSnapshotStorageKey(session: {
friendlyId: string;
chatSnapshotStoragePath: string | null;
}): string {
return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId);
}
@@ -0,0 +1,39 @@
import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { type RunListFilter, type RunListResolver } from "./runReader.server";
export type ClickHouseRunListResolverOptions = {
/** Resolves the per-organization ClickHouse client (multi-tenant routing). */
getClickhouse: (organizationId: string) => Promise<ClickHouse>;
prisma: PrismaClientOrTransaction;
};
/**
* Resolves the realtime tag/list filter into matching run ids via ClickHouse `listRunIds` (filter-only;
* rows hydrated from Postgres by id afterward). Tag matching is contains-ALL, byte-matching Electric's
* `runTags @> ARRAY[...]` shape.
*/
export class ClickHouseRunListResolver implements RunListResolver {
constructor(private readonly options: ClickHouseRunListResolverOptions) {}
async resolveMatchingRunIds(filter: RunListFilter): Promise<string[]> {
const clickhouse = await this.options.getClickhouse(filter.organizationId);
const repository = new RunsRepository({ clickhouse, prisma: this.options.prisma });
const { runIds } = await repository.listRunIds({
organizationId: filter.organizationId,
projectId: filter.projectId,
environmentId: filter.environmentId,
tags: filter.tags && filter.tags.length > 0 ? filter.tags : undefined,
// Contains-ALL, matching the Electric shape's `runTags @> ARRAY[...]` semantics.
tagsMatch: "all",
batchId: filter.batchId,
from: filter.createdAtAfter?.getTime(),
page: { size: filter.limit },
});
// listRunIds is keyset-paginated; runIds is already capped to page.size (= limit).
return runIds;
}
}
@@ -0,0 +1,48 @@
/**
* Duration string parsing for stream-basin retention / delete-on-empty
* configuration. Used by `streamBasinProvisioner` (to convert to S2's
* integer-seconds wire format) and by `env.server.ts` (to validate
* duration-shaped env vars at boot rather than at first use).
*
* Accepts the short forms (`7d`, `30d`, `365d`, `1h`, `90m`, `45s`,
* `2w`, `1y`) and the human forms (`7days`, `1week`, `1year`).
*/
const PATTERN =
/^(\d+)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hour|hours?|d|day|days?|w|week|weeks?|y|year|years?)$/;
export function isValidDuration(input: string): boolean {
return PATTERN.test(input.trim().toLowerCase());
}
/**
* Parse a duration string into seconds. Throws on garbage so a
* misconfigured env var fails loudly. Use {@link isValidDuration}
* for non-throwing validation (e.g. inside a Zod `.refine()`).
*/
export function parseDuration(input: string): number {
const trimmed = input.trim().toLowerCase();
const match = trimmed.match(PATTERN);
if (!match) {
throw new Error(`Invalid duration string: ${input}`);
}
const value = parseInt(match[1]!, 10);
const unit = match[2]!;
const multiplier = /^s/.test(unit)
? 1
: /^m(?:in|ins|inute|inutes)?$/.test(unit)
? 60
: /^h/.test(unit)
? 3600
: /^d/.test(unit)
? 86400
: /^w/.test(unit)
? 604800
: /^y/.test(unit)
? 31_536_000
: NaN;
if (!Number.isFinite(multiplier)) {
throw new Error(`Invalid duration unit: ${unit}`);
}
return value * multiplier;
}
@@ -0,0 +1,281 @@
/**
* Pure (no DB/Redis/env) Electric HTTP shape-stream wire serializer, byte-faithful to what the
* deployed `@electric-sql/client` (1.0.14 + 0.4.0) and the SDK's `SubscribeRunRawShape` expect.
* Each column value is wire-encoded as a string (or null) decoded via the `electric-schema` header;
* `up-to-date` is the only control message that makes the client emit, and re-sending a full row is idempotent.
*/
export type ElectricColumnType =
| "text"
| "timestamp"
| "int4"
| "int8"
| "float8"
| "bool"
| "jsonb";
type ElectricColumn = {
name: string;
type: ElectricColumnType;
/** Array dimensionality. 1 => `type[]` (Postgres `{a,b}` literal). */
dims?: number;
/** Array columns only: true when the column has no SQL default, so an empty value emits `null` (not `{}`). Prisma erases this distinction, so we re-derive it here. */
emptyArrayAsNull?: boolean;
};
/** Columns the realtime run feed exposes; keep in sync with `DEFAULT_ELECTRIC_COLUMNS`. `type`/`dims` drive the schema header and value encoding. */
export const RUN_ELECTRIC_COLUMNS: ReadonlyArray<ElectricColumn> = [
{ name: "id", type: "text" },
{ name: "taskIdentifier", type: "text" },
{ name: "createdAt", type: "timestamp" },
{ name: "updatedAt", type: "timestamp" },
{ name: "startedAt", type: "timestamp" },
{ name: "delayUntil", type: "timestamp" },
{ name: "queuedAt", type: "timestamp" },
{ name: "expiredAt", type: "timestamp" },
{ name: "completedAt", type: "timestamp" },
{ name: "friendlyId", type: "text" },
{ name: "number", type: "int4" },
{ name: "isTest", type: "bool" },
{ name: "status", type: "text" },
{ name: "usageDurationMs", type: "int4" },
{ name: "costInCents", type: "float8" },
{ name: "baseCostInCents", type: "float8" },
{ name: "ttl", type: "text" },
{ name: "payload", type: "text" },
{ name: "payloadType", type: "text" },
{ name: "metadata", type: "text" },
{ name: "metadataType", type: "text" },
{ name: "output", type: "text" },
{ name: "outputType", type: "text" },
{ name: "runTags", type: "text", dims: 1, emptyArrayAsNull: true },
{ name: "error", type: "jsonb" },
{ name: "realtimeStreams", type: "text", dims: 1 },
];
/** Columns that can never be skipped via `skipColumns` (mirrors realtimeClient). */
export const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
/** A single run hydrated for the realtime feed; structurally compatible with the `RunHydrator` Prisma `TaskRun` projection. */
export type RealtimeRunRow = {
id: string;
taskIdentifier: string;
createdAt: Date;
updatedAt: Date;
startedAt: Date | null;
delayUntil: Date | null;
queuedAt: Date | null;
expiredAt: Date | null;
completedAt: Date | null;
friendlyId: string;
number: number;
isTest: boolean;
status: string;
usageDurationMs: number;
costInCents: number;
baseCostInCents: number;
ttl: string | null;
payload: string;
payloadType: string;
metadata: string | null;
metadataType: string;
output: string | null;
outputType: string;
runTags: string[];
error: unknown;
realtimeStreams: string[];
};
type Operation = "insert" | "update" | "delete";
type ChangeMessage = {
key: string;
value: Record<string, string | null>;
headers: { operation: Operation };
};
type ControlMessage = {
headers: { control: "up-to-date" | "must-refetch" };
};
type ShapeMessage = ChangeMessage | ControlMessage;
const UP_TO_DATE: ControlMessage = { headers: { control: "up-to-date" } };
function effectiveSkipColumns(skipColumns: string[]): Set<string> {
return new Set(skipColumns.filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c)));
}
function quoteArrayElement(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function pgArrayLiteral(values: unknown[]): string {
if (values.length === 0) {
return "{}";
}
return `{${values.map((v) => quoteArrayElement(String(v))).join(",")}}`;
}
function serializeValue(value: unknown, column: ElectricColumn): string | null {
if (value === null || value === undefined) {
return null;
}
if (column.dims && column.dims > 0) {
if (!Array.isArray(value)) {
return null;
}
// A no-default array column stores NULL when empty, so Electric emits `null`
// (not `{}`); match that here since Prisma handed us `[]` for the NULL value.
if (value.length === 0 && column.emptyArrayAsNull) {
return null;
}
return pgArrayLiteral(value);
}
switch (column.type) {
case "bool":
// Postgres text representation; the client's parseBool accepts "t"/"f".
return value ? "t" : "f";
case "timestamp":
// The SDK's RawShapeDate appends "Z" before parsing, so we emit the ISO
// string WITHOUT the trailing "Z".
return value instanceof Date ? value.toISOString().slice(0, -1) : String(value);
case "jsonb":
return JSON.stringify(value);
case "int4":
case "int8":
case "float8":
case "text":
default:
return String(value);
}
}
/** The merge key the client uses to reassemble a row across insert/update cycles. */
export function runShapeKey(runId: string): string {
return `"public"."TaskRun"/"${runId}"`;
}
/** Encode a single run row into the wire `value` object (column -> string|null). */
export function serializeRunRow(
row: RealtimeRunRow,
skipColumns: string[] = []
): Record<string, string | null> {
const skip = effectiveSkipColumns(skipColumns);
const value: Record<string, string | null> = {};
for (const column of RUN_ELECTRIC_COLUMNS) {
if (skip.has(column.name)) {
continue;
}
value[column.name] = serializeValue((row as Record<string, unknown>)[column.name], column);
}
return value;
}
/** The `electric-schema` response header value for the (optionally trimmed) column set. */
export function buildElectricSchemaHeader(skipColumns: string[] = []): string {
const skip = effectiveSkipColumns(skipColumns);
const schema: Record<string, { type: string; dims?: number }> = {};
for (const column of RUN_ELECTRIC_COLUMNS) {
if (skip.has(column.name)) {
continue;
}
schema[column.name] = column.dims
? { type: column.type, dims: column.dims }
: { type: column.type };
}
return JSON.stringify(schema);
}
/** Initial snapshot body: an `insert` for the row (if present) then `up-to-date`; an absent row emits a bare `up-to-date` (empty shape). */
export function buildSnapshotBody(row: RealtimeRunRow | null, skipColumns: string[] = []): string {
const messages: ShapeMessage[] = [];
if (row) {
messages.push({
key: runShapeKey(row.id),
value: serializeRunRow(row, skipColumns),
headers: { operation: "insert" },
});
}
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
/** Live body when the row advanced: a full-row `update` followed by `up-to-date`. */
export function buildUpdateBody(row: RealtimeRunRow, skipColumns: string[] = []): string {
const messages: ShapeMessage[] = [
{
key: runShapeKey(row.id),
value: serializeRunRow(row, skipColumns),
headers: { operation: "update" },
},
UP_TO_DATE,
];
return JSON.stringify(messages);
}
/** Live body when nothing advanced: a bare `up-to-date` (no row emission). */
export function buildUpToDateBody(): string {
return JSON.stringify([UP_TO_DATE]);
}
export type RowChange = { row: RealtimeRunRow; operation: "insert" | "update" };
/** Multi-row body for the tag-list feed: one change message per row then `up-to-date` (empty `changes` emits a bare `up-to-date`). */
export function buildRowsBody(changes: RowChange[], skipColumns: string[] = []): string {
const messages: ShapeMessage[] = changes.map((change) => ({
key: runShapeKey(change.row.id),
value: serializeRunRow(change.row, skipColumns),
headers: { operation: change.operation },
}));
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
/** A row change whose wire `value` was already serialized (once, shared across feeds by
* the EnvChangeRouter); the per-feed `operation` is applied here. */
export type SerializedRowChange = {
runId: string;
value: Record<string, string | null>;
operation: "insert" | "update";
};
/** Like `buildRowsBody`, but from values serialized once per (runId, columnSet) upstream,
* so a run matching many feeds is serialized once and reused across their bodies. */
export function buildRowsBodyFromSerialized(changes: SerializedRowChange[]): string {
const messages: ShapeMessage[] = changes.map((change) => ({
key: runShapeKey(change.runId),
value: change.value,
headers: { operation: change.operation },
}));
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
export const INITIAL_OFFSET = "-1";
/** Opaque `<updatedAtMs>_<seq>` offset token (client `${number}_${number}` type); the first segment lets a live request detect whether the row advanced. */
export function encodeOffset(updatedAtMs: number, seq: number): string {
return `${Math.trunc(updatedAtMs)}_${Math.trunc(seq)}`;
}
/** Extract the `updatedAt` epoch-ms a client last saw from its echoed offset. */
export function parseOffsetUpdatedAtMs(offset: string | null | undefined): number {
if (!offset) {
return 0;
}
const [first] = offset.split("_");
const value = Number(first);
return Number.isFinite(value) && value > 0 ? value : 0;
}
/** Mirror of realtimeClient's DEQUEUED->EXECUTING rewrite for non-current API versions. */
export function rewriteBodyForLegacyApiVersion(body: string): string {
return body.replace(/"status":"DEQUEUED"/g, '"status":"EXECUTING"');
}
@@ -0,0 +1,695 @@
import { type ChangeRecord } from "./runChangeNotifier.server";
import { type RealtimeRunRow, serializeRunRow } from "./electricStreamProtocol.server";
import { logger } from "~/services/logger.server";
/**
* EnvChangeRouter — per-instance routing layer that fans one env's change stream out to the feeds it
* matches. Owns one subscription per env (over the RunChangeNotifier) plus an inverted index of held
* feeds, then per batch: routes via the index, batch-hydrates matched runs once per column set,
* serializes each row's wire value once, and resolves each matched feed's pending wait. Stateless across reconnects.
*/
export type WakeReason = "notify" | "timeout" | "abort";
/** A feed's membership predicate over the env stream. */
export type FeedFilter =
| { kind: "run"; runId: string }
| { kind: "tag"; tags: string[]; createdAtFloorMs?: number }
| { kind: "batch"; batchId: string };
/** A matched run handed to a feed: the hydrated row (for the feed's working-set diff) and
* its wire `value` serialized once for this feed's column set (shared across feeds). */
export type MatchedRow = { row: RealtimeRunRow; value: Record<string, string | null> };
export type WaitResult = { reason: WakeReason; rows: MatchedRow[] };
/** Minimal deps so the router is unit-testable without Redis/Postgres. */
export interface EnvChangeSource {
subscribeToEnv(environmentId: string, onBatch: (records: ChangeRecord[]) => void): () => void;
}
export interface RowHydrator {
hydrateByIds(
environmentId: string,
ids: string[],
skipColumns: string[]
): Promise<RealtimeRunRow[]>;
}
export type EnvChangeRouterOptions = {
source: EnvChangeSource;
hydrator: RowHydrator;
/** Observability: a hydrate-by-id batch ran (count = runs hydrated this tick). */
onHydrate?: (runCount: number) => void;
/** How far back (ms) a newly-armed feed replays buffered records. 0 disables replay. */
replayWindowMs?: number;
/** Cap on buffered recent records per env (latest record per run). */
replayMaxRunsPerEnv?: number;
/** How long (ms) to keep an env subscribed + buffering after its last feed closes. 0 disables. */
unsubscribeLingerMs?: number;
/** Observability: a replay scan found candidates and delivered rows (or none survived). */
onReplay?: (result: "delivered" | "empty") => void;
/** Observability: a buffered record was evicted. `cap` evictions mean the env churns more
* runs inside the window than the buffer holds (the replay guarantee is degrading). */
onReplayEviction?: (reason: "cap" | "window") => void;
/** Read-your-writes gate over the replica: delays wake-path hydrates until the replica
* should have applied the change (record.updatedAtMs + lag + margin), and re-hydrates
* rows the tripwire still finds stale. Omit to hydrate immediately (legacy behavior). */
replicaLag?: ReplicaLagGate;
};
export type ReplicaLagGate = {
/** Current replica-lag estimate (ms). */
getLagMs(): number;
/** Feedback: a hydrate provably read at least this far behind the primary. */
noteObservedLagMs(lagMs: number): void;
/** Safety margin added on top of the estimate (clock skew + scheduling). */
marginMs: number;
/** Hard cap on any single gate delay — a sick replica degrades freshness, never liveness. */
maxDelayMs: number;
/** Re-hydrate attempts for rows that still read stale after the delay. */
staleRetries: number;
/** Observability: stale rows recovered by a retry, or delivered stale after exhausting them. */
onStaleHydrate?: (outcome: "recovered" | "gave_up", runCount: number) => void;
};
const DEFAULT_REPLAY_WINDOW_MS = 2_000;
const DEFAULT_REPLAY_MAX_RUNS_PER_ENV = 512;
const DEFAULT_UNSUBSCRIBE_LINGER_MS = 5_000;
/** Handle a feed holds for the duration of one long-poll. */
export type FeedRegistration = {
/** Wait for the next batch matching this feed (or timeout/abort), with the matched runs
* hydrated + serialized for this feed's columns. One wait active at a time. */
waitForMatch(signal: AbortSignal | undefined, timeoutMs: number): Promise<WaitResult>;
/** Deregister from the index; unsubscribes the env when the last feed leaves. */
close(): void;
/** False when this instance's env subscription is younger than the replay window, so a
* change in the caller's inter-poll gap may have been missed (hop/cold start) — the
* caller should resolve once instead of holding blind. */
gapCovered: boolean;
};
type Feed = {
filter: FeedFilter;
skipColumns: string[];
columnSig: string;
/** The currently-waiting poll's resolver (null between polls). */
resolve: ((result: WaitResult) => void) | null;
/** Buffered records at or before this timestamp have been replayed (or predate this feed). */
replayCursorMs: number;
};
type EnvState = {
unsubscribe: () => void;
feeds: Set<Feed>;
byRunId: Map<string, Set<Feed>>;
byTag: Map<string, Set<Feed>>;
byBatchId: Map<string, Set<Feed>>;
/** All tag feeds, for routing partial records (no tags) as hydrate-to-classify candidates. */
tagFeeds: Set<Feed>;
/** Tag feeds with no tag filter — they match every record but are unreachable via byTag. */
unfilteredTagFeeds: Set<Feed>;
/** When this env's channel subscription started (for the gap-coverage check). */
subscribedAtMs: number;
/** Latest record per run, insertion-ordered, for replaying inter-poll gaps to newly-armed feeds. */
recent: Map<string, { record: ChangeRecord; receivedAtMs: number }>;
/** Pending teardown while the env lingers with zero feeds. */
lingerTimer?: ReturnType<typeof setTimeout>;
};
function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}
function addToIndex(index: Map<string, Set<Feed>>, key: string, feed: Feed) {
let set = index.get(key);
if (!set) {
set = new Set();
index.set(key, set);
}
set.add(feed);
}
function removeFromIndex(index: Map<string, Set<Feed>>, key: string, feed: Feed) {
const set = index.get(key);
if (set) {
set.delete(feed);
if (set.size === 0) {
index.delete(key);
}
}
}
export class EnvChangeRouter {
readonly #envs = new Map<string, EnvState>();
constructor(private readonly options: EnvChangeRouterOptions) {}
register(
environmentId: string,
filter: FeedFilter,
skipColumns: string[],
opts?: {
/** When the caller last received data for this connection. Bounds the replay to the
* true inter-poll gap; older than the window can't be proven covered. */
replaySinceMs?: number;
}
): FeedRegistration {
const env = this.#ensureEnv(environmentId);
const replayWindowMs = this.options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
const now = Date.now();
const windowFloorMs = now - replayWindowMs;
const sinceMs = opts?.replaySinceMs ?? windowFloorMs;
const feed: Feed = {
filter,
skipColumns,
columnSig: skipColumns.length > 0 ? [...skipColumns].sort().join(",") : "",
resolve: null,
// First arm replays the caller's inter-poll gap; later arms only what arrived since.
// The buffer only spans the window, so never rewind past it.
replayCursorMs: Math.max(sinceMs, windowFloorMs),
};
env.feeds.add(feed);
this.#indexFeed(env, feed);
const waitForMatch = (signal: AbortSignal | undefined, timeoutMs: number) =>
new Promise<WaitResult>((resolve) => {
if (signal?.aborted) {
resolve({ reason: "abort", rows: [] });
return;
}
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const settle = (result: WaitResult) => {
if (settled) return;
settled = true;
feed.resolve = null;
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
resolve(result);
};
feed.resolve = settle;
timer = setTimeout(() => settle({ reason: "timeout", rows: [] }), timeoutMs);
timer.unref?.();
if (signal) {
onAbort = () => settle({ reason: "abort", rows: [] });
signal.addEventListener("abort", onAbort, { once: true });
}
// Deliver any buffered records this feed hasn't seen (catches changes that
// landed while the caller was between polls).
if (replayWindowMs > 0 && env.recent.size > 0) {
this.#replayRecent(environmentId, env, feed).catch((error) => {
logger.error("[envChangeRouter] failed to replay buffered records", {
environmentId,
error,
});
});
}
});
const close = () => {
if (!env.feeds.has(feed)) {
return;
}
env.feeds.delete(feed);
this.#deindexFeed(env, feed);
// Resolve any in-flight wait so the poll doesn't hang.
feed.resolve?.({ reason: "abort", rows: [] });
feed.resolve = null;
if (env.feeds.size === 0) {
this.#scheduleEnvTeardown(environmentId, env);
}
};
return {
waitForMatch,
close,
// Covered when this instance was already subscribed (and buffering) at the gap's
// start, and the gap fits inside the buffer's window.
gapCovered:
replayWindowMs <= 0 || (env.subscribedAtMs <= sinceMs && sinceMs >= windowFloorMs),
};
}
/** Distinct environments currently routed (for metrics). */
get activeEnvCount(): number {
return this.#envs.size;
}
/** Currently-held feeds by kind (for metrics) — the system's capacity unit. */
get heldFeedCounts(): { run: number; tag: number; batch: number } {
const counts = { run: 0, tag: 0, batch: 0 };
for (const env of this.#envs.values()) {
for (const feed of env.feeds) {
counts[feed.filter.kind]++;
}
}
return counts;
}
#ensureEnv(environmentId: string): EnvState {
const existing = this.#envs.get(environmentId);
if (existing) {
// A pending teardown is cancelled by new interest; the buffer survives the gap.
if (existing.lingerTimer) {
clearTimeout(existing.lingerTimer);
existing.lingerTimer = undefined;
}
return existing;
}
const env: EnvState = {
unsubscribe: () => {},
feeds: new Set(),
byRunId: new Map(),
byTag: new Map(),
byBatchId: new Map(),
tagFeeds: new Set(),
unfilteredTagFeeds: new Set(),
subscribedAtMs: Date.now(),
recent: new Map(),
};
this.#envs.set(environmentId, env);
env.unsubscribe = this.options.source.subscribeToEnv(environmentId, (records) => {
this.#bufferRecent(env, records);
// Fire-and-forget; catch hydrate failures here (unhandled rejection exits the process) — waiters time out into the backstop.
this.#onBatch(environmentId, env, records).catch((error) => {
logger.error("[envChangeRouter] failed to route a change batch", {
environmentId,
error,
});
});
});
return env;
}
/** Keep the env subscribed + buffering for a linger after its last feed closes, so a
* client's next poll (or another instance hop landing back here) can replay the gap. */
#scheduleEnvTeardown(environmentId: string, env: EnvState) {
const lingerMs = this.options.unsubscribeLingerMs ?? DEFAULT_UNSUBSCRIBE_LINGER_MS;
if (lingerMs <= 0) {
this.#envs.delete(environmentId);
env.unsubscribe();
return;
}
if (env.lingerTimer) {
clearTimeout(env.lingerTimer);
}
env.lingerTimer = setTimeout(() => {
if (env.feeds.size === 0) {
this.#envs.delete(environmentId);
env.unsubscribe();
}
}, lingerMs);
env.lingerTimer.unref?.();
}
/** Upsert the latest record per run (insertion-ordered) and prune to the window + cap. */
#bufferRecent(env: EnvState, records: ChangeRecord[]) {
const windowMs = this.options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
if (windowMs <= 0) {
return;
}
const maxRuns = this.options.replayMaxRunsPerEnv ?? DEFAULT_REPLAY_MAX_RUNS_PER_ENV;
const now = Date.now();
for (const record of records) {
env.recent.delete(record.runId);
env.recent.set(record.runId, { record, receivedAtMs: now });
}
const cutoff = now - windowMs;
for (const [runId, entry] of env.recent) {
if (entry.receivedAtMs >= cutoff && env.recent.size <= maxRuns) {
break;
}
this.options.onReplayEviction?.(entry.receivedAtMs < cutoff ? "window" : "cap");
env.recent.delete(runId);
}
}
/** Whether a buffered record matches a feed's predicate (mirrors #onBatch's routing). */
#recordMatchesFeed(record: ChangeRecord, feed: Feed): boolean {
switch (feed.filter.kind) {
case "run":
return record.runId === feed.filter.runId;
case "batch":
return record.batchId != null && record.batchId === feed.filter.batchId;
case "tag": {
const tags = feed.filter.tags;
// Unfiltered feed matches everything; partial record (no tags) = hydrate-to-classify.
if (tags.length === 0 || record.tags === undefined) {
return true;
}
return record.tags.some((tag) => tags.includes(tag));
}
}
}
/** How long to wait before hydrating so the replica has applied every change in the
* batch: each record is safe at updatedAtMs + lag + margin (records without a watermark
* anchor at now, degrading to a plain lag-sized delay). Capped — see ReplicaLagGate. */
#gateDelayMs(records: ChangeRecord[]): number {
const gate = this.options.replicaLag;
if (!gate || records.length === 0) {
return 0;
}
const now = Date.now();
const lagMs = gate.getLagMs();
let safeAtMs = 0;
for (const record of records) {
const anchorMs = record.updatedAtMs ?? now;
safeAtMs = Math.max(safeAtMs, anchorMs + lagMs + gate.marginMs);
}
return Math.max(0, Math.min(safeAtMs - now, gate.maxDelayMs));
}
/** Deliver buffered records newer than the feed's cursor through the normal
* hydrate -> serialize -> settle pipeline. Already-seen rows diff to nothing downstream. */
async #replayRecent(environmentId: string, env: EnvState, feed: Feed) {
const cursor = feed.replayCursorMs;
feed.replayCursorMs = Date.now();
const runIds: string[] = [];
const candidateRecords: ChangeRecord[] = [];
for (const [runId, entry] of env.recent) {
if (entry.receivedAtMs > cursor && this.#recordMatchesFeed(entry.record, feed)) {
runIds.push(runId);
candidateRecords.push(entry.record);
}
}
if (runIds.length === 0 || !feed.resolve) {
return;
}
// Replayed records are usually past the lag window already (delay computes to 0); a
// just-buffered one gets the same read-your-writes gate as the live path. No tripwire
// here — a stale replay diffs to a re-emission on the next wake or backstop.
const replayDelayMs = this.#gateDelayMs(candidateRecords);
if (replayDelayMs > 0) {
await sleepMs(replayDelayMs);
if (!feed.resolve) {
return;
}
}
const hydrated = await this.options.hydrator.hydrateByIds(
environmentId,
runIds,
feed.skipColumns
);
this.options.onHydrate?.(hydrated.length);
const rows: MatchedRow[] = [];
for (const row of hydrated) {
if (feed.filter.kind === "tag" && !this.#tagRowMatches(row, feed.filter)) {
continue;
}
rows.push({ row, value: serializeRunRow(row, feed.skipColumns) });
}
if (rows.length > 0 && feed.resolve) {
this.options.onReplay?.("delivered");
feed.resolve({ reason: "notify", rows });
} else {
this.options.onReplay?.("empty");
}
}
#indexFeed(env: EnvState, feed: Feed) {
switch (feed.filter.kind) {
case "run":
addToIndex(env.byRunId, feed.filter.runId, feed);
break;
case "batch":
addToIndex(env.byBatchId, feed.filter.batchId, feed);
break;
case "tag":
env.tagFeeds.add(feed);
if (feed.filter.tags.length === 0) {
env.unfilteredTagFeeds.add(feed);
}
for (const tag of feed.filter.tags) {
addToIndex(env.byTag, tag, feed);
}
break;
}
}
#deindexFeed(env: EnvState, feed: Feed) {
switch (feed.filter.kind) {
case "run":
removeFromIndex(env.byRunId, feed.filter.runId, feed);
break;
case "batch":
removeFromIndex(env.byBatchId, feed.filter.batchId, feed);
break;
case "tag":
env.tagFeeds.delete(feed);
env.unfilteredTagFeeds.delete(feed);
for (const tag of feed.filter.tags) {
removeFromIndex(env.byTag, tag, feed);
}
break;
}
}
async #onBatch(environmentId: string, env: EnvState, records: ChangeRecord[], attempt = 0) {
// 0. Read-your-writes gate: wait out the replica's apply lag before hydrating, so the
// rows we read contain the changes the records announce. Retry attempts were
// scheduled with their own delay, so only the first pass gates here.
if (attempt === 0) {
const delayMs = this.#gateDelayMs(records);
if (delayMs > 0) {
await sleepMs(delayMs);
}
}
// 1. Route each record to the held feeds it matches; collect matched runIds per feed.
const matchedRunIdsByFeed = new Map<Feed, Set<string>>();
const addMatch = (feed: Feed, runId: string) => {
if (!feed.resolve) {
// Feed isn't currently waiting (between polls). Drop — its backstop catches gaps.
return;
}
let set = matchedRunIdsByFeed.get(feed);
if (!set) {
set = new Set();
matchedRunIdsByFeed.set(feed, set);
}
set.add(runId);
};
for (const record of records) {
// run feeds: exact runId match.
const runFeeds = env.byRunId.get(record.runId);
if (runFeeds) {
for (const feed of runFeeds) addMatch(feed, record.runId);
}
// batch feeds: exact batchId match (only when the record carries one).
if (record.batchId) {
const batchFeeds = env.byBatchId.get(record.batchId);
if (batchFeeds) {
for (const feed of batchFeeds) addMatch(feed, record.runId);
}
}
// tag feeds.
if (record.tags !== undefined) {
// Full record: prune via the tag index; only feeds whose filter intersects match.
const seen = new Set<Feed>();
for (const tag of record.tags) {
const tagFeeds = env.byTag.get(tag);
if (!tagFeeds) continue;
for (const feed of tagFeeds) {
if (seen.has(feed)) continue;
seen.add(feed);
addMatch(feed, record.runId);
}
}
// Unfiltered tag feeds match every record but live outside the index.
for (const feed of env.unfilteredTagFeeds) addMatch(feed, record.runId);
} else {
// Partial record (no membership data): route to every tag feed as a candidate to
// hydrate-and-classify (rare; the publish side emits full records in practice).
for (const feed of env.tagFeeds) addMatch(feed, record.runId);
}
}
if (matchedRunIdsByFeed.size === 0) {
return;
}
// 2. Batch-hydrate ONCE per column set, then 3. serialize ONCE per (runId, column set).
const runIdsByColumnSig = new Map<string, { skipColumns: string[]; runIds: Set<string> }>();
for (const [feed, runIds] of matchedRunIdsByFeed) {
let group = runIdsByColumnSig.get(feed.columnSig);
if (!group) {
group = { skipColumns: feed.skipColumns, runIds: new Set() };
runIdsByColumnSig.set(feed.columnSig, group);
}
for (const id of runIds) group.runIds.add(id);
}
const hydratedByColumnSig = new Map<string, Map<string, MatchedRow>>();
await Promise.all(
[...runIdsByColumnSig.entries()].map(async ([columnSig, group]) => {
const ids = [...group.runIds];
const rows = await this.options.hydrator.hydrateByIds(
environmentId,
ids,
group.skipColumns
);
this.options.onHydrate?.(rows.length);
const map = new Map<string, MatchedRow>();
for (const row of rows) {
map.set(row.id, { row, value: serializeRunRow(row, group.skipColumns) });
}
hydratedByColumnSig.set(columnSig, map);
})
);
// 3.5 Stale tripwire: a watermarked record whose hydrated row is older (or missing —
// the insert race) read a replica that hadn't applied the change. Withhold those
// rows and re-hydrate shortly. Exhausting the retry budget delivers what we have
// (liveness over freshness) — but a stale emission advances the feed's cursor, so
// it ALSO schedules echo passes past the gate: re-hydrates flowing through normal
// emission, where the working-set diff drops unchanged rows and emits the fresh
// version once the replica catches up. The backstop stays the terminal net.
// Each detection feeds the lag estimator.
const gate = this.options.replicaLag;
const isEchoPass = gate !== undefined && attempt > gate.staleRetries;
const staleRunIds = gate
? this.#detectStaleRuns(records, runIdsByColumnSig, hydratedByColumnSig)
: new Set<string>();
if (attempt > 0 && !isEchoPass) {
const recovered = new Set(records.map((r) => r.runId)).size - staleRunIds.size;
if (recovered > 0) {
gate?.onStaleHydrate?.("recovered", recovered);
}
}
if (staleRunIds.size > 0 && gate) {
const staleRecords = records.filter((record) => staleRunIds.has(record.runId));
// Re-buffer the withheld records so a feed that re-arms between now and the next
// pass replays them instead of waiting for its backstop.
this.#bufferRecent(env, staleRecords);
if (attempt >= gate.staleRetries) {
// Budget exhausted: deliver the stale rows below (liveness) — but a stale emission
// advances the feed's cursor, so keep echoing re-hydrates through normal emission
// (the working-set diff drops unchanged rows, emits the fresh version when the
// replica catches up). Echoes stop once the change ages past the horizon; deeper
// outages are the backstop's job.
if (attempt === gate.staleRetries) {
gate.onStaleHydrate?.("gave_up", staleRunIds.size);
}
staleRunIds.clear();
}
const echoHorizonMs = gate.maxDelayMs * 10;
const newestWatermarkMs = Math.max(...staleRecords.map((record) => record.updatedAtMs ?? 0));
const withinEchoHorizon = Date.now() - newestWatermarkMs < echoHorizonMs;
if (attempt < gate.staleRetries || withinEchoHorizon) {
const retryDelayMs = Math.max(
25,
Math.min(gate.getLagMs() + gate.marginMs, gate.maxDelayMs)
);
const timer = setTimeout(() => {
this.#onBatch(environmentId, env, staleRecords, attempt + 1).catch((error) => {
logger.error("[envChangeRouter] failed to re-hydrate stale rows", {
environmentId,
error,
});
});
}, retryDelayMs);
timer.unref?.();
}
}
// 4. Assemble each feed's matched rows (post-filtering tag feeds against the
// authoritative hydrated row) and resolve its pending wait.
for (const [feed, runIds] of matchedRunIdsByFeed) {
if (!feed.resolve) {
continue; // stopped waiting while we hydrated; its next poll/backstop covers it
}
const hydrated = hydratedByColumnSig.get(feed.columnSig);
if (!hydrated) continue;
const rows: MatchedRow[] = [];
for (const runId of runIds) {
if (staleRunIds.has(runId)) {
continue; // withheld; the scheduled re-hydrate delivers the fresh version
}
const matched = hydrated.get(runId);
if (!matched) continue; // run not found / left the table
if (feed.filter.kind === "tag" && !this.#tagRowMatches(matched.row, feed.filter)) {
continue; // re-confirm tags + createdAt floor against the authoritative row
}
rows.push(matched);
}
if (rows.length > 0) {
feed.resolve({ reason: "notify", rows });
}
// No surviving rows (e.g. a partial-record candidate that didn't actually match):
// leave the feed waiting; nothing relevant changed for it.
}
}
/** Runs whose hydrated row is provably behind its record's watermark (stale content),
* or absent entirely despite a watermark (the insert hasn't applied). Records without
* `updatedAtMs` can't be judged and always pass. */
#detectStaleRuns(
records: ChangeRecord[],
runIdsByColumnSig: Map<string, { skipColumns: string[]; runIds: Set<string> }>,
hydratedByColumnSig: Map<string, Map<string, MatchedRow>>
): Set<string> {
const gate = this.options.replicaLag;
const stale = new Set<string>();
if (!gate) {
return stale;
}
const expectedByRunId = new Map<string, number>();
for (const record of records) {
if (record.updatedAtMs !== undefined) {
const existing = expectedByRunId.get(record.runId);
if (existing === undefined || record.updatedAtMs > existing) {
expectedByRunId.set(record.runId, record.updatedAtMs);
}
}
}
if (expectedByRunId.size === 0) {
return stale;
}
const now = Date.now();
for (const [columnSig, group] of runIdsByColumnSig) {
const hydrated = hydratedByColumnSig.get(columnSig);
for (const runId of group.runIds) {
const expected = expectedByRunId.get(runId);
if (expected === undefined || stale.has(runId)) {
continue;
}
const matched = hydrated?.get(runId);
if (!matched || matched.row.updatedAt.getTime() < expected) {
stale.add(runId);
gate.noteObservedLagMs(now - expected);
}
}
}
return stale;
}
/** Authoritative re-check for tag feeds: the hydrated row carries ALL the filter's tags
* (Electric's `runTags @> ARRAY[...]` semantics) and its createdAt is within the window. */
#tagRowMatches(row: RealtimeRunRow, filter: Extract<FeedFilter, { kind: "tag" }>): boolean {
if (
filter.createdAtFloorMs !== undefined &&
row.createdAt.getTime() < filter.createdAtFloorMs
) {
return false;
}
const rowTags = row.runTags ?? [];
return filter.tags.every((tag) => rowTags.includes(tag));
}
}
@@ -0,0 +1,164 @@
import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt";
import { $replica } from "~/db.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
export type ValidatePublicJwtKeySuccess = {
ok: true;
environment: AuthenticatedEnvironment;
claims: Record<string, unknown>;
};
export type ValidatePublicJwtKeyError = {
ok: false;
error: string;
};
export type ValidatePublicJwtKeyResult = ValidatePublicJwtKeySuccess | ValidatePublicJwtKeyError;
export async function validatePublicJwtKey(token: string): Promise<ValidatePublicJwtKeyResult> {
// Get the sub claim from the token
// Use the sub claim to find the environment
// Validate the token against the environment.apiKey
// Once that's done, return the environment and the claims
const sub = extractJWTSub(token);
if (!sub) {
return { ok: false, error: "Invalid Public Access Token, missing subject." };
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return { ok: false, error: "Invalid Public Access Token, environment not found." };
}
let result = await validateJWT(
token,
environment.parentEnvironment?.apiKey ?? environment.apiKey
);
// PATs are signed with the env's apiKey at mint time. If the env's apiKey
// has since been rotated, signature verification fails against the current
// key — fall back to any RevokedApiKey rows still in their grace window.
// Only run this query on the failure path so the success path is unchanged.
if (!result.ok) {
result = await validateAgainstRevokedApiKeys(
token,
environment.parentEnvironment?.id ?? environment.id,
result
);
}
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
return {
ok: false,
error:
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
case "ERR_JWT_CLAIM_INVALID": {
return {
ok: false,
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
default: {
return {
ok: false,
error:
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
}
}
return {
ok: true,
environment,
claims: result.payload,
};
}
async function validateAgainstRevokedApiKeys(
token: string,
signingEnvironmentId: string,
primaryResult: ValidationResult
): Promise<ValidationResult> {
const revokedApiKeys = await $replica.revokedApiKey.findMany({
where: {
runtimeEnvironmentId: signingEnvironmentId,
expiresAt: { gt: new Date() },
},
select: { apiKey: true },
});
for (const { apiKey } of revokedApiKeys) {
const fallbackResult = await validateJWT(token, apiKey);
if (fallbackResult.ok) {
return fallbackResult;
}
}
return primaryResult;
}
export function isPublicJWT(token: string): boolean {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return false;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return false;
// Check for the pub: true claim
return "pub" in payload && payload.pub === true;
} catch (_error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return false;
}
}
export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) {
return environment.parentEnvironment?.apiKey ?? environment.apiKey;
}
function extractJWTSub(token: string): string | undefined {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return;
// Check for the pub: true claim
return "sub" in payload && typeof payload.sub === "string" ? payload.sub : undefined;
} catch (_error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return;
}
}
function decodeBase64Url(str: string): string {
// Replace URL-safe characters and add padding
str = str.replace(/-/g, "+").replace(/_/g, "/");
switch (str.length % 4) {
case 2:
str += "==";
break;
case 3:
str += "=";
break;
}
// Decode using Node.js Buffer
return Buffer.from(str, "base64").toString("utf8");
}
@@ -0,0 +1,41 @@
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { extractJwtSigningSecretKey } from "./jwtAuth.server";
type Environment = Parameters<typeof extractJwtSigningSecretKey>[0];
export type MintRunTokenOptions = {
/** Include the input-stream write scope (needed for steering messages from the playground). */
includeInputStreamWrite?: boolean;
/** Token expiration. Defaults to "1h". */
expirationTime?: string;
};
/**
* Mint a run-scoped public access token (JWT) for browser subscription to a
* run's realtime streams.
*
* Used by:
* - The playground action to give a freshly triggered chat session a token.
* - The run details page to let the agent view subscribe to the chat stream
* of an existing run (read-only).
*/
export async function mintRunToken(
environment: Environment,
runFriendlyId: string,
options: MintRunTokenOptions = {}
): Promise<string> {
const scopes = [`read:runs:${runFriendlyId}`];
if (options.includeInputStreamWrite) {
scopes.push(`write:inputStreams:${runFriendlyId}`);
}
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes,
},
expirationTime: options.expirationTime ?? "1h",
});
}
@@ -0,0 +1,40 @@
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { extractJwtSigningSecretKey } from "./jwtAuth.server";
type Environment = Parameters<typeof extractJwtSigningSecretKey>[0];
export type MintSessionTokenOptions = {
/** Token expiration. Defaults to "1h". */
expirationTime?: string;
};
/**
* Mint a session-scoped public access token (JWT) covering both `.in`
* append and `.out` subscribe for a session's realtime channels.
*
* Returned by `POST /api/v1/sessions` so the browser holds a single
* long-lived token that survives across runs (sessions outlive any
* single run). Includes both read and write scopes since the transport
* needs both: read for SSE subscribe on `.out`, write for `.in` appends
* (`stop`, follow-up messages, action chunks).
*/
export async function mintSessionToken(
environment: Environment,
sessionAddressingKey: string,
options: MintSessionTokenOptions = {}
): Promise<string> {
const scopes = [
`read:sessions:${sessionAddressingKey}`,
`write:sessions:${sessionAddressingKey}`,
];
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes,
},
expirationTime: options.expirationTime ?? "1h",
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import { getMeter } from "@internal/tracing";
import { $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { getCachedLimit } from "../platform.v3.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { ClickHouseRunListResolver } from "./clickHouseRunListResolver.server";
import { EnvChangeRouter, type EnvChangeSource } from "./envChangeRouter.server";
import { NativeRealtimeClient } from "./nativeRealtimeClient.server";
import { RealtimeConcurrencyLimiter } from "./realtimeConcurrencyLimiter.server";
import { getRunChangeNotifier } from "./runChangeNotifierInstance.server";
import { RedisReplayCursorStore } from "./replayCursorStore.server";
import { createPostgresReplicaLagSource, ReplicaLagEstimator } from "./replicaLagEstimator.server";
import { RunHydrator } from "./runReader.server";
// Process-singleton wiring for the native realtime client; only constructed when a
// request actually routes to it, so a disabled webapp never instantiates it.
function initializeNativeRealtimeClient(): NativeRealtimeClient {
const meter = getMeter("realtime-native");
const wakeups = meter.createCounter("realtime_native.wakeups", {
description:
"Live realtime wakeups by reason. A rising 'timeout' share suggests a write site is missing its publishChangeRecord delegate.",
});
const runSetResolves = meter.createCounter("realtime_native.runset_resolves", {
description:
"Multi-run (tag-list/batch) resolve+hydrate outcomes. 'hit'/'coalesced' vs 'miss' shows how effectively concurrent same-filter feeds share a single ClickHouse + Postgres query.",
});
const runSetQueryMs = meter.createHistogram("realtime_native.runset_query_ms", {
description: "Latency of the multi-run resolve (ClickHouse) and hydrate (Postgres) stages.",
unit: "ms",
});
const livePollPaths = meter.createCounter("realtime_native.live_polls", {
description:
"How live polls resolved. 'fast-hydrate' = router wake with rows hydrated by id (no ClickHouse); 'full-resolve' = backstop; 'cold-resolve' = fresh env subscription probed once.",
});
const routerHydrates = meter.createCounter("realtime_native.router_hydrated_runs", {
description:
"Runs hydrated by the EnvChangeRouter's batch-hydrate (one query per column set per wake, shared across all feeds matching the same run).",
});
const resolveAdmissionWaits = meter.createCounter("realtime_native.resolve_admission_waits", {
description:
"Fresh ClickHouse resolves that had to queue for an admission permit. A rising count means a distinct-filter reconnect stampede is being throttled (the gate is doing its job).",
});
const replays = meter.createCounter("realtime_native.replays", {
description:
"Buffered change records replayed to a newly-armed feed (inter-poll gap recovery). 'delivered' = rows reached the feed; 'empty' = candidates hydrated but none survived the filter/diff.",
});
const replayEvictions = meter.createCounter("realtime_native.replay_evictions", {
description:
"Replay-buffer evictions. 'window' expiry is normal; 'cap' means an env churns more runs inside the window than the buffer holds (replay guarantee degrading — retune the knobs).",
});
const deliveryLagMs = meter.createHistogram("realtime_native.delivery_lag_ms", {
description:
"Live emissions: now minus the newest emitted row's updatedAt (PG clock vs app clock, so approximate). The end-to-end delivery SLI — a p99 near the backstop hold means wakes are being missed.",
unit: "ms",
});
const emittedRows = meter.createHistogram("realtime_native.emitted_rows", {
description:
"Rows per live emission. Deltas should be small; a fat tail means working-set/offset-floor fallbacks are re-emitting full sets.",
unit: "rows",
});
const backstops = meter.createCounter("realtime_native.backstops", {
description:
"Backstop full resolves by outcome. 'empty' is normal idle behavior; sustained 'delivered' means the notify/replay path missed changes — alert on it.",
});
const concurrencyRejections = meter.createCounter("realtime_native.concurrency_rejections", {
description: "Polls rejected (429) by the per-env concurrency limiter.",
});
const replayCursorOps = meter.createCounter("realtime_native.replay_cursor_ops", {
description:
"Shared replay-cursor store operations by outcome. Errors degrade hops to cold resolves (watch live_polls{path='cold-resolve'} rise with them), never failed polls.",
});
const staleHydrates = meter.createCounter("realtime_native.stale_hydrates", {
description:
"Wake hydrates the read-your-writes tripwire caught reading behind the publish. 'recovered' = a retry delivered the fresh row; sustained 'gave_up' means replica lag is outrunning the retry budget.",
});
const limiter = new RealtimeConcurrencyLimiter({
keyPrefix: "tr:realtime:native:concurrency",
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
// Fleet-shared replay cursors (one timestamp per connection) on the same Redis as the
// change channel, so a load-balancer hop reads the connection's true inter-poll gap.
const replayCursorStore =
env.REALTIME_BACKEND_NATIVE_SHARED_REPLAY_CURSORS === "1"
? new RedisReplayCursorStore({
redis: {
host: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_HOST,
port: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PORT,
username: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_USERNAME,
password: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PASSWORD,
tlsDisabled: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_TLS_DISABLED === "true",
clusterMode: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_CLUSTER_MODE_ENABLED === "1",
},
ttlMs: env.REALTIME_BACKEND_NATIVE_WORKING_SET_TTL_MS,
onResult: (op, ok) => replayCursorOps.add(1, { op, result: ok ? "ok" : "error" }),
})
: undefined;
// One RunHydrator shared by the router and the client, so its single-flight + short-TTL cache covers both.
const runReader = new RunHydrator({
replica: $replica,
runStore,
cacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_TTL_MS,
maxCacheEntries: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_MAX_ENTRIES,
});
// Read-your-writes gate: the estimator samples replica lag (reader-side only, paused
// when idle) and the router delays wake hydrates by it, anchored to each record's
// updatedAtMs — so a publish racing the replica's apply is waited out, not read stale.
const lagEstimator =
env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1"
? new ReplicaLagEstimator({
source: createPostgresReplicaLagSource($replica),
sampleIntervalMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_SAMPLE_INTERVAL_MS,
idleAfterMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_IDLE_AFTER_MS,
windowMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_WINDOW_MS,
defaultLagMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_DEFAULT_MS,
observedFloorTtlMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_OBSERVED_FLOOR_TTL_MS,
})
: undefined;
// The notifier wrapped so router activity keeps the lag sampler warm.
const notifier = getRunChangeNotifier();
const source: EnvChangeSource = lagEstimator
? {
subscribeToEnv(environmentId, onBatch) {
lagEstimator.touch();
return notifier.subscribeToEnv(environmentId, (records) => {
lagEstimator.touch();
onBatch(records);
});
},
}
: notifier;
const router = new EnvChangeRouter({
source,
hydrator: runReader,
onHydrate: (runCount) => routerHydrates.add(runCount),
replayWindowMs: env.REALTIME_BACKEND_NATIVE_REPLAY_WINDOW_MS,
replayMaxRunsPerEnv: env.REALTIME_BACKEND_NATIVE_REPLAY_MAX_RUNS,
unsubscribeLingerMs: env.REALTIME_BACKEND_NATIVE_UNSUBSCRIBE_LINGER_MS,
onReplay: (result) => replays.add(1, { result }),
onReplayEviction: (reason) => replayEvictions.add(1, { reason }),
replicaLag: lagEstimator
? {
getLagMs: () => lagEstimator.getLagMs(),
noteObservedLagMs: (lagMs) => lagEstimator.noteObservedLagMs(lagMs),
marginMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_MARGIN_MS,
maxDelayMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_MAX_DELAY_MS,
staleRetries: env.REALTIME_BACKEND_NATIVE_STALE_HYDRATE_RETRIES,
onStaleHydrate: (outcome, runCount) => staleHydrates.add(runCount, { outcome }),
}
: undefined,
});
const client = new NativeRealtimeClient({
runReader,
runListResolver: new ClickHouseRunListResolver({
getClickhouse: (organizationId) =>
clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"),
prisma: $replica,
}),
router,
limiter,
cachedLimitProvider: {
async getCachedLimit(organizationId, defaultValue) {
const result = await getCachedLimit(
organizationId,
"realtimeConcurrentConnections",
defaultValue
);
return result.val;
},
},
defaultConcurrencyLimit: env.REALTIME_BACKEND_NATIVE_DEFAULT_CONCURRENCY_LIMIT,
livePollTimeoutMs: env.REALTIME_BACKEND_NATIVE_LIVE_POLL_TIMEOUT_MS,
livePollJitterRatio: env.REALTIME_BACKEND_NATIVE_LIVE_POLL_JITTER_RATIO,
maximumCreatedAtFilterAgeMs: env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS,
maxListResults: env.REALTIME_BACKEND_NATIVE_MAX_LIST_RESULTS,
runSetResolveCacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUNSET_CACHE_TTL_MS,
runSetResolveCacheMaxEntries: env.REALTIME_BACKEND_NATIVE_RUNSET_CACHE_MAX_ENTRIES,
listCacheMaxEntries: env.REALTIME_BACKEND_NATIVE_WORKING_SET_MAX_ENTRIES,
workingSetCacheTtlMs: env.REALTIME_BACKEND_NATIVE_WORKING_SET_TTL_MS,
runSetCreatedAtBucketMs: env.REALTIME_BACKEND_NATIVE_RUNSET_CREATED_AT_BUCKET_MS,
holdOnEmpty: env.REALTIME_BACKEND_NATIVE_HOLD_ON_EMPTY === "1",
resolveAdmissionLimit: env.REALTIME_BACKEND_NATIVE_RESOLVE_ADMISSION_LIMIT,
replayCursorStore,
onWakeup: (reason) => wakeups.add(1, { reason }),
onLivePollPath: (path) => livePollPaths.add(1, { path }),
onRunSetResolve: (result) => runSetResolves.add(1, { result }),
onRunSetQuery: (stage, ms) => runSetQueryMs.record(ms, { stage }),
onResolveAdmissionWait: () => resolveAdmissionWaits.add(1),
onEmit: (path, lagMs, rowCount) => {
deliveryLagMs.record(Math.max(lagMs, 0), { path });
emittedRows.record(rowCount);
},
onBackstopResult: (result) => backstops.add(1, { result }),
onConcurrencyRejected: () => concurrencyRejections.add(1),
});
meter
.createObservableGauge("realtime_native.working_set_size", {
description:
"Entries in the per-handle working-set cache (one per active multi-run feed session).",
})
.addCallback((result) => result.observe(client.workingSetCacheSize));
meter
.createObservableGauge("realtime_native.resolve_admission_in_use", {
description:
"Fresh ClickHouse resolves currently holding an admission permit (live concurrency against the gate's limit).",
})
.addCallback((result) => result.observe(client.resolveAdmissionInUse));
meter
.createObservableGauge("realtime_native.held_feeds", {
description: "Long-polls currently held, by feed kind — the system's capacity unit.",
})
.addCallback((result) => {
const counts = router.heldFeedCounts;
result.observe(counts.run, { kind: "run" });
result.observe(counts.tag, { kind: "tag" });
result.observe(counts.batch, { kind: "batch" });
});
meter
.createObservableGauge("realtime_native.active_envs", {
description:
"Environments currently routed on this instance (held feeds + lingering subscriptions).",
})
.addCallback((result) => result.observe(router.activeEnvCount));
if (lagEstimator) {
meter
.createObservableGauge("realtime_native.replica_lag_estimate_ms", {
description:
"The read-your-writes gate's current replica-lag estimate (max sample in the window). Wake hydrates are delayed by roughly this much past each change's commit.",
})
.addCallback((result) => result.observe(lagEstimator.getLagMs()));
}
return client;
}
export function getNativeRealtimeClient(): NativeRealtimeClient {
return singleton("nativeRealtimeClient", initializeNativeRealtimeClient);
}
@@ -0,0 +1,112 @@
import type { Callback, Result } from "ioredis";
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "../logger.server";
export type RealtimeConcurrencyLimiterOptions = {
redis: RedisWithClusterOptions;
keyPrefix: string;
/** How long a tracked request lives before it's swept as stale (seconds). */
expiryTimeInSeconds?: number;
connectionName?: string;
};
/**
* Per-environment concurrent-connection limiter for realtime long-polls; a standalone copy of the limiter in
* `realtimeClient.server.ts` (identical Lua + key shape, different key prefix) so the native backend tracks independently.
*/
export class RealtimeConcurrencyLimiter {
private redis: RedisClient;
private expiryTimeInSeconds: number;
constructor(private options: RealtimeConcurrencyLimiterOptions) {
this.redis = createRedisClient(
options.connectionName ?? "trigger:realtime:native:concurrency",
options.redis
);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5;
this.#registerCommands();
}
async incrementAndCheck(
environmentId: string,
requestId: string,
limit: number
): Promise<boolean> {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckRealtimeNativeConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(),
(now - this.expiryTimeInSeconds * 1000).toString(),
limit.toString()
);
return result === 1;
}
async decrement(environmentId: string, requestId: string): Promise<void> {
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckRealtimeNativeConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
return 1
`,
});
this.redis.on("error", (error) => {
logger.error("[realtimeConcurrencyLimiter] redis error", { error });
});
}
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckRealtimeNativeConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
@@ -0,0 +1,479 @@
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import type { RedisOptions } from "ioredis";
import Redis from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import type { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
export type RealtimeStreamsOptions = {
redis: RedisOptions | undefined;
logger?: Logger;
logLevel?: LogLevel;
inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 15000)
};
// Legacy constant for backward compatibility (no longer written, but still recognized when reading)
const END_SENTINEL = "<<CLOSE_STREAM>>";
// Internal types for stream pipeline
type StreamChunk =
| { type: "ping" }
| { type: "data"; redisId: string; data: string }
| { type: "legacy-data"; redisId: string; data: string };
// Class implementing both interfaces
export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
private logger: Logger;
private inactivityTimeoutMs: number;
// Shared connection for short-lived non-blocking operations (XADD, XREVRANGE, EXPIRE).
// Lazily created on first use so we don't open a connection if only streamResponse is called.
private _sharedRedis: Redis | undefined;
constructor(private options: RealtimeStreamsOptions) {
this.logger = options.logger ?? new Logger("RedisRealtimeStreams", options.logLevel ?? "info");
this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 15000; // Default: 15 seconds
}
private get sharedRedis(): Redis {
if (!this._sharedRedis) {
this._sharedRedis = new Redis({
reconnectOnError: defaultReconnectOnError,
...this.options.redis,
connectionName: "realtime:shared",
});
}
return this._sharedRedis;
}
async initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }> {
return {};
}
async streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const redis = new Redis({
reconnectOnError: defaultReconnectOnError,
...this.options.redis,
connectionName: "realtime:streamResponse",
});
const streamKey = `stream:${runId}:${streamId}`;
let isCleanedUp = false;
const stream = new ReadableStream<StreamChunk>({
start: async (controller) => {
// Start from lastEventId if provided, otherwise from beginning
let lastId = options?.lastEventId ?? "0";
let retryCount = 0;
const maxRetries = 3;
let lastDataTime = Date.now();
let lastEnqueueTime = Date.now();
const blockTimeMs = 5000;
const pingIntervalMs = 10000; // 10 seconds
if (options?.lastEventId) {
this.logger.debug("[RealtimeStreams][streamResponse] Resuming from lastEventId", {
streamKey,
lastEventId: options?.lastEventId,
});
}
try {
while (!signal.aborted) {
// Check if we need to send a ping
const timeSinceLastEnqueue = Date.now() - lastEnqueueTime;
if (timeSinceLastEnqueue >= pingIntervalMs) {
controller.enqueue({ type: "ping" });
lastEnqueueTime = Date.now();
}
// Compute inactivity threshold once to use consistently in both branches
const inactivityThresholdMs = options?.timeoutInSeconds
? options.timeoutInSeconds * 1000
: this.inactivityTimeoutMs;
try {
const messages = await redis.xread(
"COUNT",
100,
"BLOCK",
blockTimeMs,
"STREAMS",
streamKey,
lastId
);
retryCount = 0;
if (messages && messages.length > 0) {
const [_key, entries] = messages[0];
let foundData = false;
for (let i = 0; i < entries.length; i++) {
const [id, fields] = entries[i];
lastId = id;
if (fields && fields.length >= 2) {
// Extract the data field from the Redis entry
// Fields format: ["field1", "value1", "field2", "value2", ...]
let data: string | null = null;
for (let j = 0; j < fields.length; j += 2) {
if (fields[j] === "data") {
data = fields[j + 1];
break;
}
}
// Handle legacy entries that don't have field names (just data at index 1)
if (data === null && fields.length >= 2) {
data = fields[1];
}
if (data) {
// Skip legacy END_SENTINEL entries (backward compatibility)
if (data === END_SENTINEL) {
continue;
}
// Enqueue structured chunk with Redis stream ID
controller.enqueue({
type: "data",
redisId: id,
data,
});
foundData = true;
lastDataTime = Date.now();
lastEnqueueTime = Date.now();
if (signal.aborted) {
controller.close();
return;
}
}
}
}
// If we didn't find any data in this batch, might have only seen sentinels
if (!foundData) {
// Check for inactivity timeout
const inactiveMs = Date.now() - lastDataTime;
if (inactiveMs >= inactivityThresholdMs) {
this.logger.debug(
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
{
streamKey,
inactiveMs,
threshold: inactivityThresholdMs,
}
);
controller.close();
return;
}
}
} else {
// No messages received (timed out on BLOCK)
// Check for inactivity timeout
const inactiveMs = Date.now() - lastDataTime;
if (inactiveMs >= inactivityThresholdMs) {
this.logger.debug(
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
{
streamKey,
inactiveMs,
threshold: inactivityThresholdMs,
}
);
controller.close();
return;
}
}
} catch (error) {
if (signal.aborted) break;
this.logger.error(
"[RealtimeStreams][streamResponse] Error reading from Redis stream:",
{
error,
}
);
retryCount++;
if (retryCount >= maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
this.logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
error,
});
controller.error(error);
} finally {
await cleanup();
}
},
cancel: async () => {
await cleanup();
},
})
.pipeThrough(
// Transform 1: Buffer partial lines across Redis entries
(() => {
let buffer = "";
let lastRedisId = "0";
return new TransformStream<StreamChunk, StreamChunk & { line: string }>({
transform(chunk, controller) {
if (chunk.type === "ping") {
controller.enqueue(chunk as any);
} else if (chunk.type === "data" || chunk.type === "legacy-data") {
// Buffer partial lines: accumulate until we see newlines
buffer += chunk.data;
// Split on newlines
const lines = buffer.split("\n");
// The last element might be incomplete, hold it back in buffer
buffer = lines.pop() || "";
// Emit complete lines with the Redis ID of the chunk that completed them
for (const line of lines) {
if (line.trim().length > 0) {
controller.enqueue({
...chunk,
line,
});
}
}
// Update last Redis ID for next iteration
lastRedisId = chunk.redisId;
}
},
flush(controller) {
// On stream end, emit any leftover buffered text
if (buffer.trim().length > 0) {
controller.enqueue({
type: "data",
redisId: lastRedisId,
data: "",
line: buffer.trim(),
});
}
},
});
})()
)
.pipeThrough(
// Transform 2: Format as SSE
new TransformStream<StreamChunk & { line?: string }, string>({
transform(chunk, controller) {
if (chunk.type === "ping") {
controller.enqueue(`: ping\n\n`);
} else if ((chunk.type === "data" || chunk.type === "legacy-data") && chunk.line) {
// Use Redis stream ID as SSE event ID
controller.enqueue(`id: ${chunk.redisId}\ndata: ${chunk.line}\n\n`);
}
},
})
)
.pipeThrough(new TextEncoderStream());
async function cleanup() {
if (isCleanedUp) return;
isCleanedUp = true;
// disconnect() tears down the TCP socket immediately, which causes any
// pending XREAD BLOCK to reject right away instead of waiting for the
// block timeout to elapse. quit() would queue behind the blocking command.
redis.disconnect();
}
signal.addEventListener("abort", cleanup, { once: true });
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
const startChunk = resumeFromChunk ?? 0;
// Start counting from the resume point, not from 0
let currentChunkIndex = startChunk;
try {
const textStream = stream.pipeThrough(new TextDecoderStream());
const reader = textStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done || !value) {
break;
}
// Write each chunk with its index and clientId
this.logger.debug("[RedisRealtimeStreams][ingestData] Writing chunk", {
streamKey,
runId,
clientId,
chunkIndex: currentChunkIndex,
resumeFromChunk: startChunk,
value,
});
await redis.xadd(
streamKey,
"MAXLEN",
"~",
String(env.REALTIME_STREAM_MAX_LENGTH),
"*",
"clientId",
clientId,
"chunkIndex",
currentChunkIndex.toString(),
"data",
value
);
currentChunkIndex++;
}
// Set TTL for cleanup when stream is done
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof Error) {
if ("code" in error && error.code === "ECONNRESET") {
this.logger.info("[RealtimeStreams][ingestData] Connection reset during ingestData:", {
error,
});
return new Response(null, { status: 500 });
}
}
this.logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
return new Response(null, { status: 500 });
}
}
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
await redis.xadd(
streamKey,
"MAXLEN",
"~",
String(env.REALTIME_STREAM_MAX_LENGTH),
"*",
"clientId",
"",
"chunkIndex",
"0",
"data",
JSON.stringify(part) + "\n"
);
// Set TTL for cleanup when stream is done
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
}
async getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
try {
// Paginate through the stream from newest to oldest until we find this client's last chunk
const batchSize = 100;
let lastId = "+"; // Start from newest
while (true) {
const entries = await redis.xrevrange(streamKey, lastId, "-", "COUNT", batchSize);
if (!entries || entries.length === 0) {
// Reached the beginning of the stream, no chunks from this client
this.logger.debug(
"[RedisRealtimeStreams][getLastChunkIndex] No chunks found for client",
{
streamKey,
clientId,
}
);
return -1;
}
// Search through this batch for the client's last chunk
for (const [_id, fields] of entries) {
let entryClientId: string | null = null;
let chunkIndex: number | null = null;
let data: string | null = null;
for (let i = 0; i < fields.length; i += 2) {
if (fields[i] === "clientId") {
entryClientId = fields[i + 1];
}
if (fields[i] === "chunkIndex") {
chunkIndex = parseInt(fields[i + 1], 10);
}
if (fields[i] === "data") {
data = fields[i + 1];
}
}
// Skip legacy END_SENTINEL entries (backward compatibility)
if (data === END_SENTINEL) {
continue;
}
// Check if this entry is from our client and has a chunkIndex
if (entryClientId === clientId && chunkIndex !== null) {
this.logger.debug("[RedisRealtimeStreams][getLastChunkIndex] Found last chunk", {
streamKey,
clientId,
chunkIndex,
});
return chunkIndex;
}
}
// Move to next batch (older entries)
// Use the ID of the last entry in this batch as the new cursor
lastId = `(${entries[entries.length - 1][0]}`; // Exclusive range with (
}
} catch (error) {
this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error getting last chunk:", {
error,
streamKey,
clientId,
});
// Return -1 to indicate we don't know what the server has
return -1;
}
}
async readRecords(): Promise<never> {
throw new Error("readRecords is not implemented for Redis realtime streams");
}
}
@@ -0,0 +1,145 @@
import { createRedisClient, type RedisClient, type RedisWithClusterOptions } from "~/redis.server";
import { logger } from "../logger.server";
import { BoundedTtlCache } from "./boundedTtlCache";
/**
* Per-connection replay cursors ("when did this connection last receive data"), keyed by the
* env-prefixed working-set key. Sharing them fleet-wide makes an instance hop look like a normal
* inter-poll gap instead of an unknown one, so hops stop triggering cold resolves and full-window
* replays. Values are single timestamps, so the shared store stays cheap.
*/
export interface ReplayCursorStore {
/** The connection's last-response timestamp; undefined on miss OR error (the caller
* degrades to a cold probe / full-window replay, never blocks the poll). */
get(key: string): Promise<number | undefined>;
/** Fire-and-forget stamp; must never throw. */
set(key: string, ms: number): void;
}
/** Per-instance fallback with the same shape (used when the shared store is disabled, and in tests). */
export class InMemoryReplayCursorStore implements ReplayCursorStore {
readonly #cache: BoundedTtlCache<number>;
constructor(ttlMs: number, maxEntries: number) {
this.#cache = new BoundedTtlCache<number>(ttlMs, maxEntries);
}
async get(key: string): Promise<number | undefined> {
return this.#cache.get(key);
}
set(key: string, ms: number): void {
this.#cache.set(key, ms);
}
}
export type RedisReplayCursorStoreOptions = {
redis: RedisWithClusterOptions;
/** Entry TTL (ms); matches the working-set TTL so both views of a connection age out together. */
ttlMs: number;
/** Read deadline (ms): a slow or down Redis degrades the poll to a cold probe instead of stalling it. */
getTimeoutMs?: number;
keyPrefix?: string;
connectionName?: string;
/** Observability hook: a store op settled (errors are the degradation signal, not failures). */
onResult?: (op: "get" | "set", ok: boolean) => void;
};
const DEFAULT_KEY_PREFIX = "realtime:replay-cursor:";
const DEFAULT_GET_TIMEOUT_MS = 250;
const TIMED_OUT = Symbol("replay-cursor-get-timeout");
export class RedisReplayCursorStore implements ReplayCursorStore {
#client: RedisClient | undefined;
constructor(private readonly options: RedisReplayCursorStoreOptions) {}
async get(key: string): Promise<number | undefined> {
try {
const raw = await this.#getWithDeadline(this.#key(key));
if (raw === TIMED_OUT) {
this.options.onResult?.("get", false);
logger.warn("[replayCursorStore] replay-cursor read timed out", { key });
return undefined;
}
this.options.onResult?.("get", true);
if (raw === null) {
return undefined;
}
const ms = Number(raw);
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
} catch (error) {
this.options.onResult?.("get", false);
logger.error("[replayCursorStore] failed to read a replay cursor", { error, key });
return undefined;
}
}
/** GET raced against the read deadline (ioredis queues commands while disconnected, which
* would otherwise stall every poll start through an outage). */
#getWithDeadline(key: string): Promise<string | null | typeof TIMED_OUT> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(TIMED_OUT),
this.options.getTimeoutMs ?? DEFAULT_GET_TIMEOUT_MS
);
timer.unref?.();
this.#ensureClient()
.get(key)
.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
set(key: string, ms: number): void {
try {
this.#ensureClient()
.set(this.#key(key), String(ms), "PX", this.options.ttlMs)
.then(
() => this.options.onResult?.("set", true),
(error) => {
this.options.onResult?.("set", false);
logger.error("[replayCursorStore] failed to write a replay cursor", { error, key });
}
);
} catch (error) {
this.options.onResult?.("set", false);
logger.error("[replayCursorStore] failed to write a replay cursor", { error, key });
}
}
async quit(): Promise<void> {
const client = this.#client;
this.#client = undefined;
if (!client) return;
try {
// Bounded graceful QUIT; cursor writes are best-effort, so force-close beyond it.
await Promise.race([client.quit(), new Promise((resolve) => setTimeout(resolve, 500))]);
} catch {
// force-close below
}
client.disconnect();
}
#key(key: string): string {
return `${this.options.keyPrefix ?? DEFAULT_KEY_PREFIX}${key}`;
}
#ensureClient(): RedisClient {
if (!this.#client) {
this.#client = createRedisClient(
this.options.connectionName ?? "trigger:realtime:replay-cursors",
this.options.redis
);
}
return this.#client;
}
}
@@ -0,0 +1,257 @@
import { logger } from "~/services/logger.server";
/**
* ReplicaLagEstimator — tracks how far the read replica trails the primary so the
* EnvChangeRouter can delay wake-path hydrates just long enough to read their own writes.
* Two inputs: a ReplicaLagSource (active, reader-side only — never queries the primary)
* sampled on an interval while the router is busy, and passive observations fed back by
* the router's stale-hydrate tripwire. The estimate is the max over a short window —
* floored by recent observations — so spikes widen the delay immediately and decay back
* out as fresh samples land.
*/
type RawQueryable = {
$queryRawUnsafe<T = unknown>(query: string): Promise<T>;
};
/** A dialect-specific reader-side lag measure. `sampleLagMs()` returns the current lag in
* ms, or undefined when lag is genuinely unmeasurable right now (NOT an error — errors
* throw, and the composing source uses them to rule the dialect out). */
export interface ReplicaLagSource {
readonly name: string;
sampleLagMs(): Promise<number | undefined>;
}
/** Aurora: replicas share the storage layer and reject every standard WAL function;
* `aurora_replica_status()` is the only live lag source. Max across readers, since the
* `$replica` pool balances over all of them. No reader rows = `$replica` is the writer =
* no lag. Throws on non-Aurora (the function doesn't exist). */
export class AuroraReplicaLagSource implements ReplicaLagSource {
readonly name = "aurora";
constructor(private readonly db: RawQueryable) {}
async sampleLagMs(): Promise<number | undefined> {
const rows = await this.db.$queryRawUnsafe<{ lag: number | null }[]>(
`SELECT max(replica_lag_in_msec)::float8 AS lag FROM aurora_replica_status() WHERE session_id <> 'MASTER_SESSION_ID' AND replica_lag_in_msec IS NOT NULL`
);
const lag = rows[0]?.lag;
return typeof lag === "number" && Number.isFinite(lag) ? Math.max(0, lag) : 0;
}
}
/** Vanilla PG streaming replication. A primary (not in recovery — no replica configured,
* `$replica` is the writer) has no lag by definition; a caught-up replica (receive LSN ==
* replay LSN) reports 0. Mid-apply there is NO honest reader-side timestamp measure —
* `now() - pg_last_xact_replay_timestamp()` reads as the full inter-write gap on
* low-traffic systems, which (measured locally) pins the estimate at the delay cap — so
* mid-apply reports undefined and the tripwire's observed-staleness floor carries the
* estimate instead. */
export class VanillaPgReplicaLagSource implements ReplicaLagSource {
readonly name = "vanilla-pg";
constructor(private readonly db: RawQueryable) {}
async sampleLagMs(): Promise<number | undefined> {
const rows = await this.db.$queryRawUnsafe<{ caught_up: boolean | null }[]>(
`SELECT CASE
WHEN NOT pg_is_in_recovery() THEN true
WHEN pg_last_wal_receive_lsn() IS NOT DISTINCT FROM pg_last_wal_replay_lsn() THEN true
ELSE false
END AS caught_up`
);
return rows[0]?.caught_up ? 0 : undefined;
}
}
/** Composes dialect sources: the first whose sample succeeds is selected and used from
* then on; a database where none work degrades to never-measuring (the estimator then
* runs on its default + tripwire observations). Selection is by thrown-vs-returned —
* sources throw on unsupported dialects and return undefined for "can't measure now". */
export class FirstSupportedReplicaLagSource implements ReplicaLagSource {
/** undefined = not probed yet; null = no candidate works here. */
#selected: ReplicaLagSource | null | undefined;
constructor(private readonly candidates: ReplicaLagSource[]) {}
get name(): string {
return this.#selected ? this.#selected.name : "undetected";
}
async sampleLagMs(): Promise<number | undefined> {
if (this.#selected === null) {
return undefined;
}
if (this.#selected) {
// Transient errors don't unselect the dialect; the sample is just skipped.
try {
return await this.#selected.sampleLagMs();
} catch {
return undefined;
}
}
for (const candidate of this.candidates) {
try {
const lag = await candidate.sampleLagMs();
this.#selected = candidate;
logger.info("[replicaLagEstimator] selected lag source", { source: candidate.name });
return lag;
} catch {
// unsupported dialect; try the next
}
}
this.#selected = null;
logger.warn(
"[replicaLagEstimator] no usable lag source; relying on default + tripwire observations"
);
return undefined;
}
}
/** The standard composition for a Prisma replica client. */
export function createPostgresReplicaLagSource(replica: RawQueryable): ReplicaLagSource {
return new FirstSupportedReplicaLagSource([
new AuroraReplicaLagSource(replica),
new VanillaPgReplicaLagSource(replica),
]);
}
export type ReplicaLagEstimatorOptions = {
source: ReplicaLagSource;
/** Sample cadence while active. */
sampleIntervalMs?: number;
/** Stop sampling this long after the last touch(); the next touch resumes. */
idleAfterMs?: number;
/** The estimate is the max sample inside this window. */
windowMs?: number;
/** Estimate before any sample lands (and the floor when sampling is unavailable). */
defaultLagMs?: number;
/** Ceiling on accepted samples — shields the estimate from a wild observation. */
maxLagMs?: number;
/** How long a tripwire observation floors the estimate. Sources that can't measure
* mid-apply lag (vanilla PG) return nothing, so without this floor the estimate decays
* to the caught-up zeros within windowMs and every ~window one wake pays a stale retry
* to re-learn. */
observedFloorTtlMs?: number;
/** Observability: a sample (active or passive) was accepted. */
onSample?: (lagMs: number, source: "probe" | "observed") => void;
};
const DEFAULT_SAMPLE_INTERVAL_MS = 250;
const DEFAULT_IDLE_AFTER_MS = 30_000;
const DEFAULT_WINDOW_MS = 5_000;
const DEFAULT_DEFAULT_LAG_MS = 30;
const DEFAULT_MAX_LAG_MS = 60_000;
const DEFAULT_OBSERVED_FLOOR_TTL_MS = 60_000;
export class ReplicaLagEstimator {
readonly #sampleIntervalMs: number;
readonly #idleAfterMs: number;
readonly #windowMs: number;
readonly #defaultLagMs: number;
readonly #maxLagMs: number;
readonly #observedFloorTtlMs: number;
#samples: { atMs: number; lagMs: number }[] = [];
#lastKnownLagMs: number | undefined;
#observedFloorLagMs = 0;
#observedFloorAtMs = 0;
#lastTouchMs = 0;
#timer: ReturnType<typeof setInterval> | undefined;
#sampling = false;
constructor(private readonly options: ReplicaLagEstimatorOptions) {
this.#sampleIntervalMs = options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
this.#idleAfterMs = options.idleAfterMs ?? DEFAULT_IDLE_AFTER_MS;
this.#windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
this.#defaultLagMs = options.defaultLagMs ?? DEFAULT_DEFAULT_LAG_MS;
this.#maxLagMs = options.maxLagMs ?? DEFAULT_MAX_LAG_MS;
this.#observedFloorTtlMs = options.observedFloorTtlMs ?? DEFAULT_OBSERVED_FLOOR_TTL_MS;
}
/** Mark router activity; starts (or keeps) the sampler running. */
touch() {
this.#lastTouchMs = Date.now();
if (!this.#timer) {
this.#timer = setInterval(() => this.#tick(), this.#sampleIntervalMs);
this.#timer.unref?.();
// Sample immediately so the first wake after idle doesn't run on a stale estimate.
this.#tick();
}
}
/** Current lag estimate (ms): the max recent sample (else last known, else the default),
* floored by the latest tripwire observation while it's fresh. Never throws. */
getLagMs(): number {
const now = Date.now();
const cutoff = now - this.#windowMs;
let max: number | undefined;
for (const sample of this.#samples) {
if (sample.atMs >= cutoff && (max === undefined || sample.lagMs > max)) {
max = sample.lagMs;
}
}
const base = max ?? this.#lastKnownLagMs ?? this.#defaultLagMs;
const floor =
now - this.#observedFloorAtMs < this.#observedFloorTtlMs ? this.#observedFloorLagMs : 0;
return Math.max(base, floor);
}
/** Feedback from the stale-hydrate tripwire: a read provably ran at least this far
* behind the primary. Widens the estimate immediately AND floors it for a while —
* sources that can't measure mid-apply lag would otherwise decay it straight back. */
noteObservedLagMs(lagMs: number) {
const clamped = Math.min(Math.max(0, lagMs), this.#maxLagMs);
const floorExpired = Date.now() - this.#observedFloorAtMs >= this.#observedFloorTtlMs;
if (clamped >= this.#observedFloorLagMs || floorExpired) {
this.#observedFloorLagMs = clamped;
this.#observedFloorAtMs = Date.now();
}
this.#accept(lagMs, "observed");
}
stop() {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = undefined;
}
}
#accept(lagMs: number, source: "probe" | "observed") {
if (!Number.isFinite(lagMs)) {
return;
}
const clamped = Math.min(Math.max(0, lagMs), this.#maxLagMs);
const now = Date.now();
this.#samples.push({ atMs: now, lagMs: clamped });
this.#lastKnownLagMs = clamped;
const cutoff = now - this.#windowMs;
while (this.#samples.length > 0 && this.#samples[0].atMs < cutoff) {
this.#samples.shift();
}
this.options.onSample?.(clamped, source);
}
#tick() {
if (Date.now() - this.#lastTouchMs > this.#idleAfterMs) {
this.stop();
return;
}
if (this.#sampling) {
return; // a slow sample shouldn't stack
}
this.#sampling = true;
this.options.source
.sampleLagMs()
.then((lagMs) => {
if (lagMs !== undefined) {
this.#accept(lagMs, "probe");
}
})
.catch(() => {
// sampling errors never propagate; the estimate just ages
})
.finally(() => {
this.#sampling = false;
});
}
}
@@ -0,0 +1,93 @@
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import { logger } from "../logger.server";
import { type RealtimeEnvironment } from "../realtimeClient.server";
import { realtimeClient } from "../realtimeClientGlobal.server";
import { BoundedTtlCache } from "./boundedTtlCache";
import { type RealtimeStreamClient } from "./nativeRealtimeClient.server";
import { getNativeRealtimeClient } from "./nativeRealtimeClientInstance.server";
import { getShadowRealtimeClient } from "./shadowRealtimeClientInstance.server";
type RealtimeBackend = "electric" | "native" | "shadow";
// Two gates: the env master switch, then the per-org `realtimeBackend` feature flag (cached so
// long-polls don't hit the DB per request), falling back to REALTIME_BACKEND_DEFAULT.
const nativeBackendEnabled = env.REALTIME_BACKEND_NATIVE_ENABLED === "1";
const flag = singleton("realtimeBackendFlag", () => makeFlag($replica));
const backendCache = singleton(
"realtimeBackendCache",
() =>
new BoundedTtlCache<RealtimeBackend>(
env.REALTIME_BACKEND_FLAG_CACHE_TTL_MS,
env.REALTIME_BACKEND_FLAG_CACHE_MAX_ENTRIES
)
);
export async function resolveRealtimeStreamClient(
environment: RealtimeEnvironment & { organization?: { featureFlags?: unknown } }
): Promise<RealtimeStreamClient> {
if (!nativeBackendEnabled) {
return realtimeClient;
}
// The authenticated environment already carries the org's feature flags; pass them
// through so a cache miss doesn't need an extra organization read.
const orgFeatureFlags = environment.organization
? (environment.organization.featureFlags ?? {})
: undefined;
switch (await getRealtimeBackend(environment.organizationId, orgFeatureFlags)) {
case "native":
return getNativeRealtimeClient();
case "shadow":
// The client is still served Electric; the native path is diffed in the background.
return getShadowRealtimeClient();
case "electric":
default:
return realtimeClient;
}
}
async function getRealtimeBackend(
organizationId: string,
orgFeatureFlags: unknown | undefined
): Promise<RealtimeBackend> {
const cached = backendCache.get(organizationId);
if (cached !== undefined) {
return cached;
}
let backend: RealtimeBackend = env.REALTIME_BACKEND_DEFAULT;
try {
const overrides =
orgFeatureFlags !== undefined
? orgFeatureFlags
: (
await $replica.organization.findFirst({
where: { id: organizationId },
select: { featureFlags: true },
})
)?.featureFlags;
backend = await flag({
key: FEATURE_FLAG.realtimeBackend,
defaultValue: env.REALTIME_BACKEND_DEFAULT,
overrides: (overrides as Record<string, unknown>) ?? {},
});
} catch (error) {
// Never let a flag lookup failure break the realtime feed.
logger.error("[resolveRealtimeStreamClient] failed to resolve realtimeBackend flag", {
organizationId,
error,
});
backend = env.REALTIME_BACKEND_DEFAULT;
}
backendCache.set(organizationId, backend);
return backend;
}
@@ -0,0 +1,332 @@
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "../logger.server";
export const CHANGE_RECORD_VERSION = 1;
/**
* A self-describing run-change fact published once to the run's environment channel; row state is
* never on the wire. `tags` present (even `[]`) marks a "full" record a feed can classify locally;
* `tags` absent marks a "partial" record (envId+runId only) a tag feed must hydrate to classify.
*/
export type ChangeRecord = {
v: number;
runId: string;
envId: string;
tags?: string[];
batchId?: string | null;
createdAtMs?: number;
updatedAtMs?: number;
status?: string;
};
/** What a publish site provides; the notifier stamps the version. */
export type ChangeRecordInput = Omit<ChangeRecord, "v">;
export function encodeChangeRecord(record: ChangeRecord): string {
return JSON.stringify(record);
}
/** Decode a wire message into a ChangeRecord; a bare/malformed frame degrades to a partial record rather than throwing. */
export function decodeChangeRecord(message: string): ChangeRecord {
if (message.length === 0 || message[0] !== "{") {
return { v: 0, runId: message, envId: "" };
}
try {
const parsed = JSON.parse(message) as Partial<ChangeRecord>;
if (parsed && typeof parsed.runId === "string") {
return {
v: parsed.v ?? 0,
runId: parsed.runId,
envId: parsed.envId ?? "",
tags: parsed.tags,
batchId: parsed.batchId,
createdAtMs: parsed.createdAtMs,
updatedAtMs: parsed.updatedAtMs,
status: parsed.status,
};
}
} catch {
// fall through to the bare-runId fallback
}
return { v: 0, runId: message, envId: "" };
}
export type RunChangeNotifierOptions = {
redis: RedisWithClusterOptions;
/** Channel name prefix; the envId is appended inside a hash-tag for slot locality. */
channelPrefix?: string;
connectionName?: string;
/** Leading-edge throttle (ms) for the per-env channel, bounding the wake rate per env. Defaults to 100ms; 0 disables. */
envWakeCoalesceWindowMs?: number;
/** Use Redis sharded pub/sub (SSUBSCRIBE/SPUBLISH); cluster-only and requires `clusterOptions.shardedSubscribers`. Defaults to false (classic). */
shardedPubSub?: boolean;
/** Observability hook: a publish settled (ok) or failed (the leading degradation signal). */
onPublishResult?: (ok: boolean) => void;
/** Observability hook: a raw channel message arrived (pre-coalesce). */
onMessageReceived?: () => void;
/** Observability hook: a coalesced batch was delivered to listeners (records per batch). */
onBatchDelivered?: (recordCount: number) => void;
};
const DEFAULT_CHANNEL_PREFIX = "realtime:";
const DEFAULT_ENV_WAKE_COALESCE_WINDOW_MS = 100;
/**
* RunChangeNotifier — carries "run X changed" facts from write sites to the realtime feeds over ONE
* per-environment channel (`<prefix>env:{<envId>}`, hash-tagged so an env stays on one cluster slot).
* Uses one shared multiplexed subscriber per process (refcounted), created lazily, and a fire-and-forget
* `publish` that never throws — a dropped publish only costs latency because the consumer has a backstop.
*/
export class RunChangeNotifier {
#publisher: RedisClient | undefined;
#subscriber: RedisClient | undefined;
readonly #listeners = new Map<string, Set<(records: ChangeRecord[]) => void>>();
/** Per-channel accumulator of records since the last delivery, deduped by runId (latest per run wins), so a coalesced wake carries every run that moved. */
readonly #pending = new Map<string, Map<string, ChangeRecord>>();
readonly #channelPrefix: string;
readonly #connectionName: string;
readonly #coalesceWindowMs: number;
/** When true, use sharded pub/sub (SSUBSCRIBE/SPUBLISH/smessage) — see options. */
readonly #sharded: boolean;
/** Active coalescing windows per channel. */
readonly #coalesceTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Channels that received a message while their window was open (need a trailing wake). */
readonly #coalesceDirty = new Set<string>();
constructor(private readonly options: RunChangeNotifierOptions) {
this.#channelPrefix = options.channelPrefix ?? DEFAULT_CHANNEL_PREFIX;
this.#connectionName = options.connectionName ?? "trigger:realtime:run-change-notifier";
this.#coalesceWindowMs = options.envWakeCoalesceWindowMs ?? DEFAULT_ENV_WAKE_COALESCE_WINDOW_MS;
this.#sharded = options.shardedPubSub ?? false;
}
/** Fire-and-forget publish of a run-changed fact to the run's environment channel; never throws. */
publish(input: ChangeRecordInput): void {
const record: ChangeRecord = { v: CHANGE_RECORD_VERSION, ...input };
this.#publishToChannel(this.#channelForEnv(record.envId), encodeChangeRecord(record));
}
/** Fire-and-forget publish of many run-changed facts. Never throws. */
publishMany(inputs: ChangeRecordInput[]): void {
for (const input of inputs) {
this.publish(input);
}
}
#publishToChannel(channel: string, payload: string): void {
try {
const publisher = this.#ensurePublisher();
// Sharded pub/sub (SPUBLISH) routes to the channel's slot owner; classic PUBLISH
// broadcasts cluster-wide. The channel is hash-tagged by envId.
const result = this.#sharded
? publisher.spublish(channel, payload)
: publisher.publish(channel, payload);
if (typeof (result as Promise<number>)?.then === "function") {
(result as Promise<number>).then(
() => this.options.onPublishResult?.(true),
(error) => {
this.options.onPublishResult?.(false);
logger.error("[runChangeNotifier] Failed to publish run-changed notification", {
error,
channel,
});
}
);
} else {
this.options.onPublishResult?.(true);
}
} catch (error) {
this.options.onPublishResult?.(false);
logger.error("[runChangeNotifier] Failed to publish run-changed notification", {
error,
channel,
});
}
}
/** Subscribe to an env's run-change stream; refcounted over the shared subscriber (first listener SUBSCRIBEs, last UNSUBSCRIBEs). */
subscribeToEnv(environmentId: string, onBatch: (records: ChangeRecord[]) => void): () => void {
const channel = this.#channelForEnv(environmentId);
const subscriber = this.#ensureSubscriber();
let listeners = this.#listeners.get(channel);
if (!listeners) {
listeners = new Set();
this.#listeners.set(channel, listeners);
this.#subscribeChannel(subscriber, channel).catch((error) => {
logger.error("[runChangeNotifier] Failed to subscribe to run-change channel", {
error,
channel,
});
});
}
listeners.add(onBatch);
let unsubscribed = false;
return () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
const current = this.#listeners.get(channel);
if (!current) {
return;
}
current.delete(onBatch);
if (current.size === 0) {
// Drop the channel from the map only after Redis confirms UNSUBSCRIBE and no new listener re-subscribed in the meantime.
this.#unsubscribeChannel(subscriber, channel)
.then(() => {
const latest = this.#listeners.get(channel);
if (!latest) {
return;
}
if (latest.size === 0) {
this.#listeners.delete(channel);
} else {
// A listener arrived during the in-flight UNSUBSCRIBE; re-subscribe so it keeps receiving (the backstop covers the gap).
this.#subscribeChannel(subscriber, channel).catch((error) => {
logger.error("[runChangeNotifier] Failed to re-subscribe to run-change channel", {
error,
channel,
});
});
}
})
.catch((error) => {
// UNSUBSCRIBE failed (likely still subscribed in Redis): keep the empty map entry so a future subscriber reuses it.
logger.error("[runChangeNotifier] Failed to unsubscribe from run-change channel", {
error,
channel,
});
});
}
};
}
/** Number of distinct env channels currently subscribed (for metrics). */
get activeSubscriptionCount(): number {
return this.#listeners.size;
}
async quit(): Promise<void> {
for (const timer of this.#coalesceTimers.values()) {
clearTimeout(timer);
}
this.#coalesceTimers.clear();
this.#coalesceDirty.clear();
this.#pending.clear();
await Promise.allSettled([this.#subscriber?.quit(), this.#publisher?.quit()]);
this.#subscriber = undefined;
this.#publisher = undefined;
this.#listeners.clear();
}
#ensurePublisher(): RedisClient {
if (!this.#publisher) {
// Publishes are fire-and-forget with a consumer-side backstop, so a dropped publish is
// latency-only. Cap retries (vs ioredis's default 20) so a pub/sub outage rejects publishes
// after ~1 reconnect cycle instead of buffering them in memory across the fleet.
this.#publisher = createRedisClient(`${this.#connectionName}:pub`, {
...this.options.redis,
maxRetriesPerRequest: 1,
});
}
return this.#publisher;
}
#ensureSubscriber(): RedisClient {
if (!this.#subscriber) {
const subscriber = createRedisClient(`${this.#connectionName}:sub`, this.options.redis);
const onMessage = (channel: string, message: string) => this.#onMessage(channel, message);
// Classic pub/sub delivers "message"; sharded pub/sub delivers "smessage". Register
// both so the delivery path is identical regardless of mode.
subscriber.on("message", onMessage);
subscriber.on("smessage", onMessage);
this.#subscriber = subscriber;
}
return this.#subscriber;
}
/** SUBSCRIBE (classic) vs SSUBSCRIBE (sharded, cluster-only). */
#subscribeChannel(subscriber: RedisClient, channel: string): Promise<unknown> {
return this.#sharded ? subscriber.ssubscribe(channel) : subscriber.subscribe(channel);
}
/** UNSUBSCRIBE (classic) vs SUNSUBSCRIBE (sharded, cluster-only). */
#unsubscribeChannel(subscriber: RedisClient, channel: string): Promise<unknown> {
return this.#sharded ? subscriber.sunsubscribe(channel) : subscriber.unsubscribe(channel);
}
#onMessage(channel: string, message: string) {
this.options.onMessageReceived?.();
// Accumulate the decoded record (deduped by runId) before delivering, so a coalesced
// wake carries every run that moved during the window.
this.#addPending(channel, decodeChangeRecord(message));
if (this.#coalesceWindowMs > 0) {
this.#deliverCoalesced(channel);
return;
}
this.#deliver(channel);
}
/** Accumulate a record into the channel's pending batch, deduped by runId (a later
* record for the same run replaces the earlier one, keeping the freshest keys). */
#addPending(channel: string, record: ChangeRecord) {
let batch = this.#pending.get(channel);
if (!batch) {
batch = new Map();
this.#pending.set(channel, batch);
}
batch.set(record.runId, record);
}
#deliver(channel: string) {
// Drain the accumulated batch (and clear it) so listeners woken now get every run that
// changed since the last delivery, and a later message starts a fresh batch.
const batchMap = this.#pending.get(channel);
const batch = batchMap ? [...batchMap.values()] : [];
this.#pending.delete(channel);
const listeners = this.#listeners.get(channel);
if (!listeners || batch.length === 0) {
return;
}
this.options.onBatchDelivered?.(batch.length);
for (const onBatch of [...listeners]) {
onBatch(batch);
}
}
/** Leading-edge throttle capping the wake rate to ~1/window: deliver the first wake immediately, then one trailing wake per window while activity continues. Lossless. */
#deliverCoalesced(channel: string) {
if (this.#coalesceTimers.has(channel)) {
this.#coalesceDirty.add(channel);
return;
}
this.#deliver(channel);
this.#openCoalesceWindow(channel);
}
#openCoalesceWindow(channel: string) {
const timer = setTimeout(() => {
this.#coalesceTimers.delete(channel);
if (this.#coalesceDirty.delete(channel)) {
this.#deliver(channel);
this.#openCoalesceWindow(channel);
}
}, this.#coalesceWindowMs);
// Don't let a pending coalescing window hold the process open at shutdown.
timer.unref?.();
this.#coalesceTimers.set(channel, timer);
}
// Hash-tagged (`...{<envId>}`) so all of an env's traffic maps to one cluster slot (one
// shard) under sharded pub/sub.
#channelForEnv(environmentId: string): string {
return `${this.#channelPrefix}env:{${environmentId}}`;
}
}
@@ -0,0 +1,87 @@
import { env } from "~/env.server";
import { engine } from "~/v3/runEngine.server";
import { logger } from "../logger.server";
import { publishChangeRecord } from "./runChangeNotifierInstance.server";
/**
* Builds and publishes a self-describing `ChangeRecord` for the lifecycle events whose engine-bus payload
* already carries env + tags + batchId. Terminal transitions, runAttemptFailed, and runMetadataUpdated publish
* from `runEngineHandlers.server.ts` instead. Coverage isn't exhaustive — a dropped transition only adds latency
* because the consumer has a periodic backstop full-resolve. The env master switch is `REALTIME_BACKEND_NATIVE_ENABLED`.
*/
export function registerRunChangeNotifierHandlers() {
// Return truthy in every path so singleton() caches this factory and never re-runs it (re-running would attach duplicate engine-bus listeners on dev reload).
if (env.REALTIME_BACKEND_NATIVE_ENABLED !== "1") {
return true;
}
// Run created: the first signal for a brand-new run (born QUEUED with no status transition), so it surfaces before ClickHouse ingests it.
engine.eventBus.on("runCreated", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Status transitions (checkpoint suspend/resume, pending version, dequeue).
engine.eventBus.on("runStatusChanged", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Dequeue/lock (sets startedAt) and attempt start (DEQUEUED -> EXECUTING) — the
// most-watched "my run started" transitions.
engine.eventBus.on("runLocked", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runAttemptStarted", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runRetryScheduled", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Delay lifecycle (delayUntil / queued-after-delay changes).
engine.eventBus.on("runDelayRescheduled", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runEnqueuedAfterDelay", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
logger.info("[runChangeNotifier] realtime change-record builder registered");
return true;
}
@@ -0,0 +1,99 @@
import { getMeter } from "@internal/tracing";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "../logger.server";
import { RunChangeNotifier, type ChangeRecordInput } from "./runChangeNotifier.server";
/**
* Process-singleton wiring for the RunChangeNotifier plus the gated convenience functions write sites
* delegate to. The notifier is constructed lazily, so `REALTIME_BACKEND_NATIVE_ENABLED=0` (default) opens no Redis connections.
*/
const nativeBackendEnabled = env.REALTIME_BACKEND_NATIVE_ENABLED === "1";
function initializeRunChangeNotifier(): RunChangeNotifier {
const clusterMode = env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_CLUSTER_MODE_ENABLED === "1";
// Sharded pub/sub only works against a cluster; classic pub/sub there would
// broadcast every message to every node, so this is what actually shards load.
const shardedPubSub =
clusterMode && env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_SHARDED_ENABLED === "1";
const meter = getMeter("realtime-notifier");
const publishes = meter.createCounter("realtime_notifier.publishes", {
description:
"Change-record publishes by outcome. Failures are the leading indicator that feeds are degrading to their backstops (pub/sub Redis trouble).",
});
const received = meter.createCounter("realtime_notifier.messages_received", {
description: "Raw channel messages received by this instance's subscriber, pre-coalesce.",
});
const delivered = meter.createCounter("realtime_notifier.batches_delivered", {
description:
"Coalesced batches delivered to listeners. received/batches = the coalesce ratio (how hard a busy env is being collapsed).",
});
const notifier = new RunChangeNotifier({
redis: {
host: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_HOST,
port: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PORT,
username: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_USERNAME,
password: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PASSWORD,
tlsDisabled: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_TLS_DISABLED === "true",
clusterMode,
// One subscriber connection per shard so SSUBSCRIBE routes to the slot owner.
...(shardedPubSub ? { clusterOptions: { shardedSubscribers: true } } : {}),
},
envWakeCoalesceWindowMs: env.REALTIME_BACKEND_NATIVE_ENV_WAKE_COALESCE_WINDOW_MS,
shardedPubSub,
onPublishResult: (ok) => publishes.add(1, { result: ok ? "ok" : "error" }),
onMessageReceived: () => received.add(1),
onBatchDelivered: () => delivered.add(1),
});
meter
.createObservableGauge("realtime_notifier.active_subscriptions", {
description: "Distinct env channels currently subscribed for realtime change notifications.",
})
.addCallback((result) => result.observe(notifier.activeSubscriptionCount));
return notifier;
}
/** Lazily construct (and memoize) the notifier singleton. */
export function getRunChangeNotifier(): RunChangeNotifier {
return singleton("runChangeNotifier", initializeRunChangeNotifier);
}
/** Whether the notifier subsystem is enabled for this process. */
export function isRunChangeNotifierEnabled(): boolean {
return nativeBackendEnabled;
}
/** Fire-and-forget publish of a run-changed record. No-op (and no notifier construction)
* when disabled, so publish sites can call it unconditionally. */
export function publishChangeRecord(input: ChangeRecordInput): void {
if (!nativeBackendEnabled) {
return;
}
// Publish runs on the run-engine event bus / metadata flush loop; lazy init + encoding happen
// before the notifier's own try/catch, so guard the whole call — it must never throw at its caller.
try {
getRunChangeNotifier().publish(input);
} catch (error) {
logger.error("[runChangeNotifier] publishChangeRecord threw; dropping notification", { error });
}
}
export function publishManyChangeRecords(inputs: ChangeRecordInput[]): void {
if (!nativeBackendEnabled) {
return;
}
try {
getRunChangeNotifier().publishMany(inputs);
} catch (error) {
logger.error("[runChangeNotifier] publishManyChangeRecords threw; dropping notifications", {
error,
});
}
}
@@ -0,0 +1,174 @@
import {
type Prisma,
type PrismaClient,
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import type { RunStore } from "@internal/run-store";
import { BoundedTtlCache } from "./boundedTtlCache";
import { RESERVED_COLUMNS, type RealtimeRunRow } from "./electricStreamProtocol.server";
/**
* RunReader — the pluggable read half of the native-backend realtime feed: ClickHouse is filter-only
* (resolves ids), Postgres always hydrates row columns. Owns the `RunHydrator` (by-id) and the
* `RunListResolver` interface (the tag/list filter -> id-set seam, implemented over ClickHouse).
*/
/** The TaskRun columns the realtime feed projects (mirrors DEFAULT_ELECTRIC_COLUMNS). */
export const RUN_HYDRATOR_SELECT = {
id: true,
taskIdentifier: true,
createdAt: true,
updatedAt: true,
startedAt: true,
delayUntil: true,
queuedAt: true,
expiredAt: true,
completedAt: true,
friendlyId: true,
number: true,
isTest: true,
status: true,
usageDurationMs: true,
costInCents: true,
baseCostInCents: true,
ttl: true,
payload: true,
payloadType: true,
metadata: true,
metadataType: true,
output: true,
outputType: true,
runTags: true,
error: true,
realtimeStreams: true,
} satisfies Prisma.TaskRunSelect;
/** Columns hydrated regardless of `skipColumns`: `id` keys the row, `updatedAt` drives the offset and working-set diff. */
const ALWAYS_HYDRATED_COLUMNS = new Set<string>(["id", "updatedAt", ...RESERVED_COLUMNS]);
/** Project `RUN_HYDRATOR_SELECT` down to the columns the client didn't skip (plus
* the always-needed ones). An empty skip set returns the full select unchanged. */
export function buildHydratorSelect(skipColumns: string[] = []): Prisma.TaskRunSelect {
if (skipColumns.length === 0) {
return RUN_HYDRATOR_SELECT;
}
const skip = new Set(skipColumns);
const select: Record<string, boolean> = {};
for (const column of Object.keys(RUN_HYDRATOR_SELECT)) {
if (ALWAYS_HYDRATED_COLUMNS.has(column) || !skip.has(column)) {
select[column] = true;
}
}
return select as Prisma.TaskRunSelect;
}
export type RunListFilter = {
organizationId: string;
projectId: string;
environmentId: string;
/** Contains-ANY tag match (OR). Omit/empty for non-tag feeds. */
tags?: string[];
/** Restrict to a single batch (internal batch id) — the batch feed. */
batchId?: string;
/** Lower bound on createdAt (the tag-list feed pins this; batch omits it). */
createdAtAfter?: Date;
/** Hard cap on the result set so a broad filter can't unbound the snapshot. */
limit: number;
};
/** Resolves a tag/list filter into the matching run id-set, filter-only (rows hydrated from Postgres by id afterward). ClickHouse impl in `clickHouseRunListResolver.server.ts`. */
export interface RunListResolver {
resolveMatchingRunIds(filter: RunListFilter): Promise<string[]>;
}
export type RunHydratorOptions = {
/** A read-replica Prisma client (`$replica`). Always Postgres. */
replica: Pick<PrismaClient, "taskRun">;
/** RunStore the reads are routed through; `replica` is passed as the read client. */
runStore: RunStore;
/** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */
cacheTtlMs?: number;
/** Hard cap on cache entries before expired entries are swept. */
maxCacheEntries?: number;
};
const DEFAULT_CACHE_TTL_MS = 250;
const DEFAULT_MAX_CACHE_ENTRIES = 5_000;
/** Hydrates runs by id through the runStore seam (split routing lives in the store, below this file), projected to the realtime columns; concurrent same-run refetches are single-flighted + short-TTL cached. */
export class RunHydrator {
readonly #inflight = new Map<string, Promise<RealtimeRunRow | null>>();
readonly #cache: BoundedTtlCache<RealtimeRunRow | null>;
readonly #cacheTtlMs: number;
constructor(private readonly options: RunHydratorOptions) {
this.#cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
this.#cache = new BoundedTtlCache(
this.#cacheTtlMs,
options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
);
}
async getRunById(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const key = `${environmentId}:${runId}`;
if (this.#cacheTtlMs > 0) {
// A cached null is a valid "run not found" hit; only undefined is a miss.
const cached = this.#cache.get(key);
if (cached !== undefined) {
return cached;
}
}
const existing = this.#inflight.get(key);
if (existing) {
return existing;
}
const promise = this.#fetch(environmentId, runId).finally(() => this.#inflight.delete(key));
this.#inflight.set(key, promise);
const row = await promise;
if (this.#cacheTtlMs > 0) {
this.#cache.set(key, row);
}
return row;
}
/** Hydrate many runs by id in one query (order not guaranteed); `skipColumns` projects the SELECT so dropped columns aren't shipped. */
async hydrateByIds(
environmentId: string,
ids: string[],
skipColumns: string[] = []
): Promise<RealtimeRunRow[]> {
if (ids.length === 0) {
return [];
}
const rows = await this.options.runStore.findRuns(
{
where: {
runtimeEnvironmentId: environmentId,
id: { in: ids },
},
select: buildHydratorSelect(skipColumns),
},
this.options.replica as PrismaClientOrTransaction
);
return rows as unknown as RealtimeRunRow[];
}
async #fetch(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const run = await this.options.runStore.findRun(
{
id: runId,
runtimeEnvironmentId: environmentId,
},
{ select: RUN_HYDRATOR_SELECT },
this.options.replica as PrismaClientOrTransaction
);
return (run ?? null) as RealtimeRunRow | null;
}
}
@@ -0,0 +1,742 @@
// app/realtime/S2RealtimeStreams.ts
import type { UnkeyCache } from "@internal/cache";
import type { StreamIngestor, StreamRecord, StreamResponder, StreamResponseOptions } from "./types";
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import { headerValue } from "@trigger.dev/core/v3";
import { randomUUID } from "node:crypto";
import { ServiceValidationError } from "~/v3/services/common.server";
// S2's per-record metered-size limit. Verified empirically against
// cloud S2: append succeeds at metered=1048576 and 422s at 1048577
// with `"record must have metered size less than 1 MiB"` (the "less
// than" wording is slightly off — the boundary is inclusive).
//
// Metered size formula:
// metered = 8 (record overhead) + 2*H + Σ(header name + value) + body
// where `body` is the unescaped record body length in UTF-8 bytes and
// `H` is the number of S2 record headers.
//
// We attach no record headers (H=0), so the budget reduces to:
// 8 + body ≤ 1048576 → body ≤ 1048568
export const S2_MAX_METERED_BYTES = 1024 * 1024; // 1 MiB
export const S2_RECORD_BASE_OVERHEAD_BYTES = 8;
/**
* Thrown when a record's metered size would exceed S2's hard per-record
* limit. Caught by the route handler and surfaced as 413.
*/
export class S2RecordTooLargeError extends ServiceValidationError {
constructor(public readonly meteredBytes: number) {
super(
`Record metered size ${meteredBytes} bytes exceeds the S2 per-record limit of ${S2_MAX_METERED_BYTES} bytes. Reduce tool-output size or split into smaller parts.`,
413
);
this.name = "S2RecordTooLargeError";
}
}
export type S2RealtimeStreamsOptions = {
// S2
basin: string; // e.g., "my-basin"
accessToken: string; // "Bearer" token issued in S2 console
streamPrefix?: string; // defaults to ""
// Custom endpoint for s2-lite (self-hosted)
endpoint?: string; // e.g., "http://localhost:4566/v1"
// Skip access token issuance (s2-lite doesn't support /access-tokens)
skipAccessTokens?: boolean;
// Read behavior
s2WaitSeconds?: number;
flushIntervalMs?: number; // how often to flush buffered chunks (default 200ms)
maxRetries?: number; // max number of retries for failed flushes (default 10)
logger?: Logger;
logLevel?: LogLevel;
accessTokenExpirationInMs?: number;
cache?: UnkeyCache<{
accessToken: string;
}>;
};
// Ops the issued S2 access token is scoped to. `trim` is a distinct op
// from `append` even though trim records are appended like any other —
// without it, `AppendRecord.trim()` 403s with "Operation not permitted".
// `chat.agent`'s per-turn trim chain depends on it.
//
// The fingerprint folds the ops list into the cache key, so any future
// scope change auto-invalidates pre-deploy cached tokens.
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");
type S2IssueAccessTokenResponse = { access_token: string };
type S2AppendInput = { records: { body: string }[] };
type S2AppendAck = {
start: { seq_num: number; timestamp: number };
end: { seq_num: number; timestamp: number };
tail: { seq_num: number; timestamp: number };
};
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
private readonly basin: string;
private readonly baseUrl: string;
private readonly accountUrl: string;
private readonly endpoint?: string;
private readonly token: string;
private readonly streamPrefix: string;
private readonly skipAccessTokens: boolean;
private readonly s2WaitSeconds: number;
private readonly flushIntervalMs: number;
private readonly maxRetries: number;
private readonly logger: Logger;
private readonly level: LogLevel;
private readonly accessTokenExpirationInMs: number;
private readonly cache?: UnkeyCache<{
accessToken: string;
}>;
constructor(opts: S2RealtimeStreamsOptions) {
this.basin = opts.basin;
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
this.endpoint = opts.endpoint;
this.token = opts.accessToken;
this.streamPrefix = opts.streamPrefix ?? "";
this.skipAccessTokens = opts.skipAccessTokens ?? false;
this.s2WaitSeconds = opts.s2WaitSeconds ?? 60;
this.flushIntervalMs = opts.flushIntervalMs ?? 200;
this.maxRetries = opts.maxRetries ?? 10;
this.logger = opts.logger ?? new Logger("S2RealtimeStreams", opts.logLevel ?? "info");
this.level = opts.logLevel ?? "info";
this.cache = opts.cache;
this.accessTokenExpirationInMs = opts.accessTokenExpirationInMs ?? 60_000 * 60 * 24; // 1 day
}
private toStreamName(runId: string, streamId: string): string {
return `${this.streamPrefix}/runs/${runId}/${streamId}`;
}
/**
* Build an S2 stream name for a `Session`-primitive channel, addressed by
* the session's `friendlyId` and the I/O direction. Used by the session
* realtime routes to route traffic to `sessions/{friendlyId}/{out|in}`.
*/
public toSessionStreamName(friendlyId: string, io: "out" | "in"): string {
return `${this.streamPrefix}/sessions/${friendlyId}/${io}`;
}
async initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }> {
return this.#initializeStreamByName(
this.toStreamName(runId, streamId),
`/runs/${runId}/${streamId}`
);
}
/**
* Initialize an S2 stream by `(sessionFriendlyId, io)` — mirrors
* {@link initializeStream} but addresses the new `sessions/*` key format.
*/
async initializeSessionStream(
friendlyId: string,
io: "out" | "in"
): Promise<{ responseHeaders?: Record<string, string> }> {
return this.#initializeStreamByName(
this.toSessionStreamName(friendlyId, io),
`/sessions/${friendlyId}/${io}`
);
}
async #initializeStreamByName(
prefixedName: string,
relativeName: string
): Promise<{ responseHeaders?: Record<string, string> }> {
const accessToken = this.skipAccessTokens
? this.token
: await this.getS2AccessToken(randomUUID());
return {
responseHeaders: {
"X-S2-Access-Token": accessToken,
"X-S2-Stream-Name": this.skipAccessTokens ? prefixedName : relativeName,
"X-S2-Basin": this.basin,
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
"X-S2-Max-Retries": this.maxRetries.toString(),
...(this.endpoint ? { "X-S2-Endpoint": this.endpoint } : {}),
},
};
}
ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response> {
throw new Error("S2 streams are written to S2 via the client, not from the server");
}
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
await this.#appendPartByName(part, partId, this.toStreamName(runId, streamId));
}
/** Append one record to a `Session` channel; returns its seq (same space as `session-in-event-id`). */
async appendPartToSessionStream(
part: string,
partId: string,
friendlyId: string,
io: "out" | "in"
): Promise<number> {
return this.#appendPartByName(part, partId, this.toSessionStreamName(friendlyId, io));
}
async #appendPartByName(part: string, partId: string, s2Stream: string): Promise<number> {
this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream });
const recordBody = JSON.stringify({ data: part, id: partId });
const meteredBytes = Buffer.byteLength(recordBody, "utf8") + S2_RECORD_BASE_OVERHEAD_BYTES;
if (meteredBytes > S2_MAX_METERED_BYTES) {
throw new S2RecordTooLargeError(meteredBytes);
}
const result = await this.s2Append(s2Stream, {
records: [{ body: recordBody }],
});
this.logger.debug(`S2 append result`, { result });
return result.start.seq_num;
}
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
throw new Error("S2 streams are written to S2 via the client, not from the server");
}
async readRecords(
runId: string,
streamId: string,
afterSeqNum?: number
): Promise<StreamRecord[]> {
return this.#readRecordsByName(this.toStreamName(runId, streamId), afterSeqNum);
}
/**
* Read records from a `Session`-primitive channel starting after the
* given sequence number. Used by the `.wait()` race-check path.
*/
async readSessionStreamRecords(
friendlyId: string,
io: "out" | "in",
afterSeqNum?: number
): Promise<StreamRecord[]> {
return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io), afterSeqNum);
}
async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise<StreamRecord[]> {
const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0;
const qs = new URLSearchParams();
qs.set("seq_num", String(startSeq));
qs.set("clamp", "true");
qs.set("wait", "0"); // Non-blocking: return immediately with existing records
const res = await fetch(
`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
}
);
if (!res.ok) {
// Stream may not exist yet (no data sent)
if (res.status === 404) {
return [];
}
const text = await res.text().catch(() => "");
throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText} ${text}`);
}
// Parse the SSE response body to extract records
const body = await res.text();
return this.parseSSEBatchRecords(body);
}
private parseSSEBatchRecords(sseText: string): StreamRecord[] {
const records: StreamRecord[] = [];
// SSE events are separated by double newlines
const events = sseText.split("\n\n").filter((e) => e.trim());
for (const event of events) {
const lines = event.split("\n");
let eventType: string | undefined;
let data: string | undefined;
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data = line.slice(5).trim();
}
}
if (eventType === "batch" && data) {
try {
const parsed = JSON.parse(data) as {
records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};
for (const record of parsed.records) {
// S2 command records (trim/fence) have a single header with
// empty name. Skip — callers want only data + Trigger control
// records.
if (record.headers?.[0]?.[0] === "") {
continue;
}
// Data records carry a JSON envelope; Trigger control records
// have an empty body and route via headers. Tolerate non-JSON
// bodies so a control record (or a malformed data record)
// doesn't take the whole batch down with it.
let parsedBody: { data: string; id: string } | undefined;
try {
parsedBody = JSON.parse(record.body) as { data: string; id: string };
} catch {
parsedBody = undefined;
}
records.push({
data: parsedBody?.data ?? "",
id: parsedBody?.id ?? "",
seqNum: record.seq_num,
headers: record.headers,
});
}
} catch {
// Skip malformed events
}
}
}
return records;
}
// ---------- Serve SSE from S2 ----------
async streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
return this.#streamResponseByName(this.toStreamName(runId, streamId), signal, options);
}
/**
* Serve SSE from a `Session`-primitive channel addressed by
* `(friendlyId, io)`.
*
* For `io=out`, peek the tail of the stream. If the most recent
* non-command record is a `turn-complete` control record (i.e. the
* agent has finished a turn and is either idle-waiting on `.in` or
* has exited), no more chunks will arrive without further user
* action. We switch the downstream S2 read to `wait=0` (drain
* whatever's left, close fast) and set `X-Session-Settled: true` so
* the client knows this SSE close is terminal instead of the normal
* 60s long-poll cycle.
*
* The actual tail is now usually an S2 `trim` command record (the
* agent appends one after every turn-complete to keep `.out`
* bounded). The peek reads two records and walks past the trim to
* find the turn-complete underneath.
*
* Mid-turn tail (streaming UIMessageChunk) falls through to the
* long-poll path; a crashed-mid-turn stream is indistinguishable
* here and behaves like today (client sees wait=60 close, retries).
*/
async streamResponseFromSessionStream(
request: Request,
friendlyId: string,
io: "out" | "in",
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const s2Stream = this.toSessionStreamName(friendlyId, io);
let waitSeconds = options?.timeoutInSeconds ?? this.s2WaitSeconds;
let settled = false;
// Only peek + settle when the client opts in via `options.peekSettled`.
// Reconnect-on-reload paths (`TriggerChatTransport.reconnectToStream`)
// set it; active send-a-message paths don't — otherwise the peek
// races the newly-triggered turn's first chunk and the SSE closes
// before records land.
if (io === "out" && options?.peekSettled) {
settled = await this.#peekIsSettled(s2Stream);
if (settled) {
waitSeconds = 0;
}
}
const s2Response = await this.#streamResponseByName(s2Stream, signal, {
...options,
timeoutInSeconds: waitSeconds,
});
if (!settled) return s2Response;
const headers = new Headers(s2Response.headers);
headers.set("X-Session-Settled", "true");
return new Response(s2Response.body, {
status: s2Response.status,
statusText: s2Response.statusText,
headers,
});
}
/**
* Peek the tail of `.out` and return whether the stream is "settled" —
* i.e. the most recent non-command record is a `turn-complete` control
* record. The agent appends an S2 `trim` command record immediately
* after every turn-complete to keep the stream bounded, so we read two
* tail records and walk past any trim command to find the
* turn-complete underneath.
*/
async #peekIsSettled(s2Stream: string): Promise<boolean> {
const qs = new URLSearchParams();
// `tail_offset=2` rewinds two seq positions; `count=2` caps it to
// those two records. At steady state these are `[turn-complete, trim]`.
// `wait=0` returns immediately with no long-poll.
qs.set("tail_offset", "2");
qs.set("count", "2");
qs.set("wait", "0");
let res: Response;
try {
res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
});
} catch (err) {
this.logger.warn("S2 peek last record: fetch failed", { err, stream: s2Stream });
return false;
}
if (!res.ok) {
// 404: stream has never been written to. 416: range not
// satisfiable (empty stream). Both mean "nothing to peek."
if (res.status === 404 || res.status === 416) return false;
const text = await res.text().catch(() => "");
this.logger.warn("S2 peek last record failed", {
status: res.status,
statusText: res.statusText,
text,
stream: s2Stream,
});
return false;
}
let records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
try {
const json = (await res.json()) as {
records?: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};
records = json.records ?? [];
} catch (err) {
this.logger.warn("S2 peek last record: parse failed", { err, stream: s2Stream });
return false;
}
// Walk from most-recent backward, skipping S2 command records
// (`headers[0][0] === ""`). The first non-command record is the
// real tail — settled iff its `trigger-control` header is
// `turn-complete`.
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]!;
if (record.headers?.[0]?.[0] === "") {
continue;
}
const controlValue = headerValue(record.headers, "trigger-control");
return controlValue === "turn-complete";
}
return false;
}
async #streamResponseByName(
s2Stream: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const startSeq = this.parseLastEventId(options?.lastEventId);
this.logger.info(`S2 streaming records from stream`, { stream: s2Stream, startSeq });
// Request SSE stream from S2 and return it directly
const s2Response = await this.s2StreamRecords(s2Stream, {
seq_num: startSeq ?? 0,
clamp: true,
wait: options?.timeoutInSeconds ?? this.s2WaitSeconds, // S2 will keep the connection open and stream new records
signal, // Pass abort signal so S2 connection is cleaned up when client disconnects
});
// Return S2's SSE response directly to the client
return s2Response;
}
// ---------- Internals: S2 REST ----------
private async s2Append(stream: string, body: S2AppendInput): Promise<S2AppendAck> {
// POST /v1/streams/{stream}/records (JSON).
//
// Retries transient failures (network errors and 5xx) up to 3 times with
// exponential backoff. Undici's "fetch failed" errors observed locally
// are pre-connection (DNS/TCP) so the request never reaches S2, making
// retry safe — the alternative is a 500 surfacing to the SDK transport,
// which then retries the whole `/in/append` round-trip and pollutes
// logs. 4xx are not retried (genuine client errors).
const url = `${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`;
const init: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
body: JSON.stringify(body),
};
const maxAttempts = 3;
const backoffsMs = [100, 250, 600];
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// The `try` only wraps `fetch` — once we have a Response we handle status
// outside the catch, so a 4xx throw can't be swallowed and retried.
let res: Response | undefined;
try {
res = await fetch(url, init);
} catch (err) {
lastError = err;
}
if (res) {
if (res.ok) {
return (await res.json()) as S2AppendAck;
}
const text = await res.text().catch(() => "");
const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`);
if (res.status >= 400 && res.status < 500) {
// 4xx — caller-side problem (auth, malformed body, closed stream).
// Retrying won't help.
throw httpError;
}
// 5xx — retryable.
lastError = httpError;
}
const isLastAttempt = attempt === maxAttempts - 1;
const diagnostics = describeFetchError(lastError);
if (isLastAttempt) {
this.logger.error("S2 append failed after retries", {
stream,
attempts: maxAttempts,
...diagnostics,
});
break;
}
this.logger.warn("S2 append transient failure, retrying", {
stream,
attempt: attempt + 1,
nextDelayMs: backoffsMs[attempt],
...diagnostics,
});
await new Promise((resolve) => setTimeout(resolve, backoffsMs[attempt]));
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private async getS2AccessToken(id: string): Promise<string> {
if (!this.cache) {
return this.s2IssueAccessToken(id);
}
// Cache key includes basin so per-org basins never collide on
// cached tokens, and the ops fingerprint so a scope change in code
// (e.g. adding `trim` in #3644) auto-invalidates pre-deploy entries
// instead of returning stale tokens for up to 24h.
const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`;
const result = await this.cache.accessToken.swr(cacheKey, async () => {
return this.s2IssueAccessToken(id);
});
if (!result.val) {
throw new Error("Failed to get S2 access token");
}
return result.val;
}
private async s2IssueAccessToken(id: string): Promise<string> {
// POST /v1/access-tokens
const res = await fetch(`${this.accountUrl}/access-tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
scope: {
basins: {
exact: this.basin,
},
ops: [...S2_TOKEN_OPS],
streams: {
prefix: this.streamPrefix,
},
},
expires_at: new Date(Date.now() + this.accessTokenExpirationInMs).toISOString(),
auto_prefix_streams: true,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`S2 issue access token failed: ${res.status} ${res.statusText} ${text}`);
}
const data = (await res.json()) as S2IssueAccessTokenResponse;
return data.access_token;
}
private async s2StreamRecords(
stream: string,
opts: {
seq_num?: number;
clamp?: boolean;
wait?: number;
signal?: AbortSignal;
}
): Promise<Response> {
// GET /v1/streams/{stream}/records with Accept: text/event-stream for SSE streaming
const qs = new URLSearchParams();
if (opts.seq_num != null) qs.set("seq_num", String(opts.seq_num));
if (opts.clamp != null) qs.set("clamp", String(opts.clamp));
if (opts.wait != null) qs.set("wait", String(opts.wait));
const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records?${qs}`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
signal: opts.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`S2 stream failed: ${res.status} ${res.statusText} ${text}`);
}
const headers = new Headers(res.headers);
headers.set("X-Stream-Version", "v2");
headers.set("Access-Control-Expose-Headers", "*");
return new Response(res.body, {
headers,
status: res.status,
statusText: res.statusText,
});
}
private parseLastEventId(lastEventId?: string): number | undefined {
if (!lastEventId) return undefined;
// tolerate formats like "1699999999999-5" (take leading digits)
const digits = lastEventId.split("-")[0];
const n = Number(digits);
return Number.isFinite(n) && n >= 0 ? n + 1 : undefined;
}
}
// Pulls the underlying network error out of undici's generic "fetch failed".
// undici sets `error.cause` to either a SystemError-shaped object with `code`
// (e.g. `ECONNRESET`, `UND_ERR_SOCKET`, `ETIMEDOUT`), `errno`, and `syscall`,
// or — for happy-eyeballs / multi-address connect attempts — an
// `AggregateError` whose `errors[]` each carry their own code. Surfacing
// those tells us whether failures are pre-connection (DNS / TCP), mid-stream
// socket resets, or genuine S2 server errors.
function describeFetchError(err: unknown): Record<string, unknown> {
if (!(err instanceof Error)) {
return { error: String(err) };
}
const out: Record<string, unknown> = {
error: err.message,
name: err.name,
};
const cause = (err as { cause?: unknown }).cause;
if (cause && typeof cause === "object") {
const c = cause as Record<string, unknown>;
if (typeof c.code === "string") out.causeCode = c.code;
if (typeof c.errno === "number" || typeof c.errno === "string") out.causeErrno = c.errno;
if (typeof c.syscall === "string") out.causeSyscall = c.syscall;
if (typeof c.message === "string") out.causeMessage = c.message;
if (Array.isArray(c.errors)) {
out.causeErrors = c.errors
.filter((e: unknown): e is Error => e instanceof Error)
.map((e) => ({
message: e.message,
code: (e as { code?: unknown }).code,
syscall: (e as { syscall?: unknown }).syscall,
address: (e as { address?: unknown }).address,
port: (e as { port?: unknown }).port,
}));
}
}
return out;
}
@@ -0,0 +1,525 @@
import type { Session, TaskRunStatus } from "@trigger.dev/database";
import { SessionTriggerConfig as SessionTriggerConfigZod } from "@trigger.dev/core/v3";
import type { z } from "zod";
import { prisma, $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
/**
* Schema for `Session.triggerConfig` (stored as JSONB). The wire-format
* source of truth lives in `@trigger.dev/core/v3` as `SessionTriggerConfig`;
* we re-export it here for the trigger machinery to validate on read.
*
* `basePayload` carries the customer's wire payload (for chat.agent:
* `{ chatId, ...clientData, idleTimeoutInSeconds? }`). Runtime fields
* specific to a particular trigger (e.g. `trigger: "trigger" | "preload"`,
* an `isContinuation` flag) come in via the `payloadOverrides` argument
* to `ensureRunForSession` and shallow-merge on top of `basePayload`.
*/
export const SessionTriggerConfigSchema = SessionTriggerConfigZod;
export type SessionTriggerConfig = z.infer<typeof SessionTriggerConfigSchema>;
export type EnsureRunReason = "initial" | "continuation" | "upgrade" | "manual";
/**
* Hard cap on how many times `ensureRunForSession` will recurse on the
* pathological "we lost the claim race AND the winner's run was already
* terminal" path. In practice progress through the run engine bounds
* this, but a misconfigured task that crashes before it can be dequeued
* could otherwise loop without limit. After this many attempts we
* surface `SessionRunManagerError` so the caller can 5xx instead of
* blowing the stack.
*/
const ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS = 3;
type EnsureRunForSessionParams = {
/**
* Session row to operate on. Caller is responsible for the env match —
* we don't re-check `runtimeEnvironmentId` against `environment.id`.
*
* `friendlyId` is used to pre-populate `payload.sessionId` on the new
* run so the agent's `chat.agent` boot path can attach to `session.in/.out`
* without a control-plane round-trip. `currentRunId` is also forwarded
* as `payload.previousRunId` (with `continuation: true`) when the prior
* run is dead, so the agent's boot gate triggers snapshot.read + replay
* instead of treating the run as a fresh chat.
*/
session: Pick<
Session,
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
>;
environment: AuthenticatedEnvironment;
reason: EnsureRunReason;
/**
* Shallow-merged on top of `triggerConfig.basePayload`. Runtime fields
* only — caller-controlled data that varies per trigger (`trigger:
* "preload"` vs `"trigger"`, etc).
*/
payloadOverrides?: Record<string, unknown>;
/**
* @internal Recursion-guard counter for the lost-claim-race retry path.
* Public callers should leave this unset; the function recurses with
* an incremented value on the pathological "winner's run was already
* terminal" branch and throws once it exceeds
* {@link ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS}.
*/
_attempt?: number;
};
export type EnsureRunResult = {
runId: string;
/** True if this call triggered a fresh run; false if it reused an alive existing one. */
triggered: boolean;
};
/**
* Idempotently make sure the session has a live run.
*
* Algorithm:
* 1. If `currentRunId` is set, probe its status. Alive → return as-is.
* 2. Trigger a new run upfront (cheap to cancel if we lose the race).
* 3. Atomic claim via `updateMany` keyed on `currentRunVersion`.
* - Won: return new runId, record SessionRun audit row.
* - Lost: cancel our triggered run, re-read session, reuse winner's
* run if alive. If pathological (winner's run already terminal),
* recurse.
*
* No DB lock is held across the trigger call. Wasted-trigger window is
* the rare multi-tab race on a dead run; cancel cost is negligible and
* the run-engine handles it gracefully.
*/
export async function ensureRunForSession(
params: EnsureRunForSessionParams
): Promise<EnsureRunResult> {
const { session, environment, reason, payloadOverrides, _attempt = 1 } = params;
if (_attempt > ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS) {
throw new SessionRunManagerError(
`ensureRunForSession exceeded ${ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS} attempts for session ${session.id} — every triggered run reached a terminal state before claim could resolve`
);
}
// 1. Probe currentRunId.
let priorDeadRunFriendlyId: string | undefined;
if (session.currentRunId) {
let probe = await getRunStatusAndFriendlyId(session.currentRunId);
if (!probe) {
// Replica miss on a row we just observed via `currentRunId` — the
// run was likely triggered moments ago and hasn't replicated yet.
// Re-probe the writer BEFORE deciding liveness: treating a lagging
// replica as "row vanished" double-triggers the session (a fast
// first append after session create races the replica apply delay
// and spawns a second live run consuming the same `.in`).
probe = await runStore.findRun(
{ id: session.currentRunId },
{ select: { status: true, friendlyId: true } },
prisma
);
}
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: session.currentRunId, triggered: false };
}
// Either the row vanished on the writer too (probe null) or its status
// is final. Either way the prior run isn't going to consume new
// appends — but the session may still hold conversation state on
// `session.out` and an S3 snapshot keyed on `session.friendlyId`.
// Forward the prior run's public-form id (friendlyId — same shape as
// `ctx.run.id`) to the agent as `previousRunId` so its boot gate flips
// `couldHavePriorState` and replays the persisted state instead of
// treating this as a fresh chat. See `chat.agent`'s boot orchestration
// in `packages/trigger-sdk/src/v3/ai.ts`.
priorDeadRunFriendlyId = probe?.friendlyId ?? session.currentRunId;
}
// 2. Validate config + trigger upfront. Continuation overrides
// (`continuation`, `previousRunId`) are derived from session state above
// and merged AFTER caller-supplied overrides — caller can't accidentally
// unset them on a session that has had a prior run, but can still
// override `trigger`/`metadata` etc. `sessionId` is always set so the
// agent doesn't need a control-plane round-trip to look up the session
// friendlyId from `payload.chatId`.
// Continuation overrides strip the basePayload's first-run-only fields
// so a continuation run doesn't inherit a stale boot payload. The Session
// row's `triggerConfig.basePayload` is captured at create-time and used
// verbatim for every Run we trigger; if the customer included `message`
// / `messages` / `trigger: "submit-message"` to make the FIRST run boot
// straight into a first turn (via `chat.createStartSessionAction`), those
// values stick around and get replayed on every continuation. With
// `continuation: true` and `message`/`messages` cleared, the SDK boot
// path enters its continuation-wait branch and waits for the next
// session.in record before running a turn.
const continuationOverrides: Record<string, unknown> = {
sessionId: session.friendlyId,
...(priorDeadRunFriendlyId !== undefined
? {
continuation: true,
previousRunId: priorDeadRunFriendlyId,
// Clear sticky boot-payload fields so the new run waits for the
// next session.in record instead of re-processing whatever was
// in the original `createStartSessionAction({ basePayload })`.
message: undefined,
messages: undefined,
trigger: undefined,
}
: {}),
};
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
...continuationOverrides,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides: mergedPayloadOverrides,
});
// 3. Try to claim the slot atomically.
const claim = await prisma.session.updateMany({
where: {
id: session.id,
currentRunVersion: session.currentRunVersion,
},
data: {
currentRunId: triggered.id,
currentRunVersion: { increment: 1 },
},
});
if (claim.count === 1) {
// Won. Audit the SessionRun. Best-effort — failure here doesn't
// invalidate the live run, just leaves a missing audit row.
prisma.sessionRun
.create({
data: { sessionId: session.id, runId: triggered.id, reason },
})
.catch((error) => {
logger.warn("Failed to record SessionRun audit row", {
sessionId: session.id,
runId: triggered.id,
reason,
error,
});
});
return { runId: triggered.id, triggered: true };
}
// 4. Lost the race. Cancel our triggered run; reuse the winner's.
cancelLostRaceRun(triggered.id, environment).catch((error) => {
logger.warn("Failed to cancel lost-race session run", {
sessionId: session.id,
runId: triggered.id,
error,
});
});
// Read-after-write: the winner just wrote `currentRunId` /
// `currentRunVersion` on the writer. Reading from `$replica` could
// return pre-race state and cause us to recurse with the same stale
// version, losing the next claim, until we exhaust max attempts.
const fresh = await prisma.session.findFirst({
where: { id: session.id },
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
triggerConfig: true,
currentRunId: true,
currentRunVersion: true,
},
});
if (!fresh) {
// Session vanished mid-flight. Surface as an error — caller decides
// whether to 404 or retry.
throw new SessionRunManagerError(`Session ${session.id} not found after lost claim race`);
}
if (fresh.currentRunId) {
// Same read-after-write reason as the `fresh` reload above: the winner
// just wrote `currentRunId` on the writer, so probe the writer too —
// the replica may not have the run row yet, and a missed probe forces
// another trigger+recurse until `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS`.
const probe = await runStore.findRun(
{ id: fresh.currentRunId },
{ select: { status: true, friendlyId: true } },
prisma
);
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: fresh.currentRunId, triggered: false };
}
}
// Pathological: winner's run already terminal. Recurse with the fresh
// version. Bounded by `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS` so a task
// that always crashes before being dequeued surfaces as an error
// instead of a stack overflow.
return ensureRunForSession({
session: fresh,
environment,
reason,
payloadOverrides,
_attempt: _attempt + 1,
});
}
/**
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
* by shallow-merging `payloadOverrides` over `config.basePayload` and
* threading `config`'s machine/queue/tags through the trigger options.
*/
async function triggerSessionRun(params: {
session: Pick<Session, "id" | "taskIdentifier">;
config: SessionTriggerConfig;
environment: AuthenticatedEnvironment;
payloadOverrides?: Record<string, unknown>;
}): Promise<{ id: string; friendlyId: string }> {
const { session, config, environment, payloadOverrides } = params;
const payload = {
...config.basePayload,
...(config.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: config.idleTimeoutInSeconds }
: {}),
...(payloadOverrides ?? {}),
};
const body = {
payload,
context: {},
options: {
...(config.machine ? { machine: config.machine as never } : {}),
...(config.queue ? { queue: { name: config.queue } } : {}),
...(config.tags ? { tags: config.tags } : {}),
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
...(config.region ? { region: config.region } : {}),
},
};
const service = new TriggerTaskService();
const result = await service.call(session.taskIdentifier, environment, body, {
triggerSource: "session",
triggerAction: "trigger",
});
if (!result) {
throw new SessionRunManagerError(
`TriggerTaskService returned no result for taskIdentifier=${session.taskIdentifier}`
);
}
return { id: result.run.id, friendlyId: result.run.friendlyId };
}
type SwapSessionRunParams = {
/**
* Session row to swap. `friendlyId` is forwarded as `payload.sessionId`
* on the new run so the agent attaches to `session.in/.out` without a
* control-plane round-trip (same convention as
* {@link EnsureRunForSessionParams}).
*/
session: Pick<
Session,
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
>;
/**
* The run requesting the swap. Optimistic claim requires
* `Session.currentRunId === callingRunId` so the swap can't clobber
* a run triggered out-of-band (e.g. a parallel `.in/append` probe
* that already replaced the dead run).
*
* Also forwarded as `payload.previousRunId` on the new run alongside
* `continuation: true` — every swap is a continuation by construction
* (`chat.requestUpgrade` / `chat.endRun` deliberately hand off prior
* conversation state to a new run), so the agent's boot gate flips
* `couldHavePriorState` and replays the snapshot + session.out tail.
*/
callingRunId: string;
environment: AuthenticatedEnvironment;
reason: EnsureRunReason;
payloadOverrides?: Record<string, unknown>;
};
export type SwapSessionRunResult = {
/** runId of the newly-triggered run that has taken over the session. */
runId: string;
/**
* False when the swap was preempted (currentRunId is no longer the
* calling run). The caller should treat this as "someone else
* already moved on" — exit cleanly without expecting to drive the
* next run.
*/
swapped: boolean;
};
/**
* Force-swap the session to a freshly-triggered run, regardless of
* whether the current run is alive. Called by `end-and-continue` when
* the running agent wants a clean handoff (typically version upgrade).
*
* Differs from `ensureRunForSession`: never reuses the current run.
* The optimistic claim is keyed on `currentRunId === callingRunId`, so
* a parallel append-time probe that already swapped to a different
* run wins the race and `swapped: false` is surfaced.
*/
export async function swapSessionRun(params: SwapSessionRunParams): Promise<SwapSessionRunResult> {
const { session, callingRunId, environment, reason, payloadOverrides } = params;
// `callingRunId` is the internal cuid (`Session.currentRunId` stores
// cuid; the route handler resolves the wire's friendlyId before passing
// it here). The agent's `previousRunId` is customer-visible and must
// match the public `run_*` form exposed via `ctx.run.id` — resolve
// before forwarding.
const callingRunFriendlyId = await resolveRunFriendlyId(callingRunId);
// Continuation overrides — unconditionally set on swap. Unlike
// `ensureRunForSession`, there's no dead-run-detection branch here:
// every swap is a deliberate handoff from `callingRunId` (which owned
// prior conversation state) to a fresh run. Merged AFTER caller-supplied
// overrides so a caller can't accidentally unset them.
//
// Sticky boot-payload fields (`message` / `messages` / `trigger`) are
// cleared here for the same reason as in `ensureRunForSession`: the
// Session's basePayload is captured at create-time and replays on every
// continuation if not stripped. See the comment in `ensureRunForSession`.
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
sessionId: session.friendlyId,
continuation: true,
previousRunId: callingRunFriendlyId,
message: undefined,
messages: undefined,
trigger: undefined,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides: mergedPayloadOverrides,
});
const claim = await prisma.session.updateMany({
where: {
id: session.id,
currentRunId: callingRunId,
currentRunVersion: session.currentRunVersion,
},
data: {
currentRunId: triggered.id,
currentRunVersion: { increment: 1 },
},
});
if (claim.count === 1) {
prisma.sessionRun
.create({
data: { sessionId: session.id, runId: triggered.id, reason },
})
.catch((error) => {
logger.warn("Failed to record SessionRun audit row", {
sessionId: session.id,
runId: triggered.id,
reason,
error,
});
});
return { runId: triggered.id, swapped: true };
}
// Lost the race — someone else already swapped to a new run. Cancel
// ours, surface the existing winner.
cancelLostRaceRun(triggered.id, environment).catch((error) => {
logger.warn("Failed to cancel preempted swap run", {
sessionId: session.id,
runId: triggered.id,
error,
});
});
// Read-after-write: the winner's swap was just committed on the
// writer. A replica read could return the pre-swap `currentRunId`
// (often `callingRunId` itself), which would tell the caller it is
// still the canonical run when in fact a different run has taken
// over.
const fresh = await prisma.session.findFirst({
where: { id: session.id },
select: { currentRunId: true },
});
// Mirror `ensureRunForSession`'s "session vanished" branch: if we
// can't find the row (or it has no current run) on the writer right
// after losing the race, surface as an error rather than handing back
// `callingRunId` with `swapped: false` — that would tell the caller
// it's still the canonical run when in fact we don't know who is.
if (!fresh?.currentRunId) {
throw new SessionRunManagerError(
`Session ${session.id} has no currentRunId after preempted swap`
);
}
return {
runId: fresh.currentRunId,
swapped: false,
};
}
async function getRunStatusAndFriendlyId(
runId: string
): Promise<{ status: TaskRunStatus; friendlyId: string } | null> {
// Use the read replica — this is a hot-path probe and stale-by-ms is
// fine. The append handler re-checks if it ends up reusing the runId.
// `friendlyId` is fetched alongside `status` so the dead-run-detection
// branch in `ensureRunForSession` can forward the public-form id as
// `payload.previousRunId` without a second read. `Session.currentRunId`
// stores the internal cuid; the agent's wire / customer hooks expose
// the friendlyId via `ctx.run.id`, so consistency matters.
const row = await runStore.findRun(
{ id: runId },
{ select: { status: true, friendlyId: true } },
$replica
);
return row ?? null;
}
/**
* Resolve a TaskRun cuid to its friendlyId. Used by `swapSessionRun` to
* forward the calling run's public-form id as `payload.previousRunId` on
* the new run. Falls back to the cuid on lookup miss so the swap doesn't
* fail just because the read replica hasn't caught up — the agent only
* uses `previousRunId` for customer-visible bookkeeping (e.g.
* `runs.retrieve(previousRunId)`), so a stale-but-non-null value is
* acceptable degraded behavior.
*/
async function resolveRunFriendlyId(runId: string): Promise<string> {
const row = await runStore.findRun({ id: runId }, { select: { friendlyId: true } }, $replica);
return row?.friendlyId ?? runId;
}
async function cancelLostRaceRun(
runId: string,
environment: AuthenticatedEnvironment
): Promise<void> {
const service = new CancelTaskRunService();
// Read-after-write: the run was just triggered on the writer, so go
// through `prisma`. A `$replica` miss here would silently no-op the
// cancel and leak an orphan run that no session is going to claim.
const run = await runStore.findRun({ id: runId }, prisma);
if (!run) return;
await service.call(run, { reason: "Lost session-run claim race" });
}
export class SessionRunManagerError extends Error {
readonly name = "SessionRunManagerError";
}
@@ -0,0 +1,194 @@
import type { PrismaClient, Session } from "@trigger.dev/database";
import type { SessionItem } from "@trigger.dev/core/v3";
import type { RunStore } from "@internal/run-store";
import { $replica, prisma } from "~/db.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
/**
* Prefix that {@link SessionId.generate} attaches to every Session friendlyId.
* Used to distinguish friendlyId lookups (`session_abc...`) from externalId
* lookups on the public `GET /api/v1/sessions/:session` route.
*/
const SESSION_FRIENDLY_ID_PREFIX = "session_";
/**
* Resolve a session from a URL path parameter that may contain either a
* friendlyId (`session_abc...`) or a user-supplied externalId.
*
* Disambiguated by prefix: values starting with `session_` are treated as
* friendlyIds, anything else is looked up against `externalId` scoped to
* the caller's environment.
*/
// CONTROL-PLANE: `Session` lives on the control-plane DB; these reads are NOT
// routed to run-ops read-through — only the `TaskRun` currentRunId resolves in
// this file are run-ops read-through routed.
export async function resolveSessionByIdOrExternalId(
prisma: Pick<PrismaClient, "session">,
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
if (isSessionFriendlyIdForm(idOrExternalId)) {
return prisma.session.findFirst({
where: { friendlyId: idOrExternalId, runtimeEnvironmentId },
});
}
// `findFirst` rather than `findUnique` per the repo rule — `findUnique`'s
// implicit DataLoader has open correctness bugs in Prisma 6.x that bite
// hot-path lookups exactly like this one.
return prisma.session.findFirst({
where: { runtimeEnvironmentId, externalId: idOrExternalId },
});
}
/**
* Replica-first session resolution with a writer fallback on miss. For the
* hot realtime routes (append / SSE subscribe / end-and-continue): a fresh
* session's first append or subscribe can arrive inside the replica's apply
* window, and a bare replica miss there surfaces as a 404 (or a subscribe
* that never finds its session) for a session that exists on the writer.
*/
export async function resolveSessionWithWriterFallback(
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
const row = await resolveSessionByIdOrExternalId($replica, runtimeEnvironmentId, idOrExternalId);
if (row) return row;
return resolveSessionByIdOrExternalId(prisma, runtimeEnvironmentId, idOrExternalId);
}
/** True for `session_*` friendlyId form, false for everything else. */
export function isSessionFriendlyIdForm(value: string): boolean {
return value.startsWith(SESSION_FRIENDLY_ID_PREFIX);
}
/**
* Canonicalise the addressing key used for everything stream-level: the
* S2 stream path and the run-engine waitpoint cache key. `chat.agent`
* and the rest of the operational surface always pass `externalId`, but
* a public-API caller may legitimately address by `friendlyId` — and a
* session created without an `externalId` only has a friendlyId at all.
*
* Rule:
* - If we have a Session row, the canonical key is `externalId` if
* set, else `friendlyId`. This way two callers addressing the same
* row via different forms always converge to the same S2 stream.
* - If we have no row (yet — chat.agent's transport may subscribe
* before the agent's bind-time upsert lands), the canonical key is
* whatever the URL had. Operationally that's always an externalId.
* Friendlyid-form callers without a matching row are rejected by
* the route handler before this is reached.
*/
export function canonicalSessionAddressingKey(row: Session | null, paramSession: string): string {
if (row) {
return row.externalId ?? row.friendlyId;
}
return paramSession;
}
/**
* Convert a Prisma `Session` row to the public {@link SessionItem} wire format.
* Strips internal columns (project/environment/organization ids) and narrows
* the `metadata` JSON to a record.
*
* Note: `currentRunId` is left as-is — Prisma stores the internal run id
* (cuid), but `SessionItem.currentRunId` is the *friendly* form. Routes
* that emit `SessionItem`s must translate: single-row endpoints via
* {@link serializeSessionWithFriendlyRunId}, list endpoints via the
* batched {@link serializeSessionsWithFriendlyRunIds}. Never put this
* raw form on the wire directly.
*/
export function serializeSession(session: Session): SessionItem {
return {
id: session.friendlyId,
externalId: session.externalId,
type: session.type,
taskIdentifier: session.taskIdentifier,
triggerConfig: session.triggerConfig as SessionItem["triggerConfig"],
currentRunId: session.currentRunId,
tags: session.tags,
metadata: (session.metadata ?? null) as SessionItem["metadata"],
closedAt: session.closedAt,
closedReason: session.closedReason,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
/**
* Same as {@link serializeSession} but resolves `currentRunId` from the
* internal cuid to the public `run_*` friendlyId via a TaskRun lookup.
* Single-row endpoints (`POST/GET/PATCH/close /api/v1/sessions/:s`) use
* this so the wire-side `currentRunId` is consistent with the rest of
* the public API (which only accepts friendlyIds for run lookups).
*
* Skips the lookup when `currentRunId` is null.
*
* Resolves `currentRunId` -> `friendlyId` through `runStore.findRun` so a
* run-ops id (NEW-DB) session run resolves from its owning store rather than the
* control-plane replica. Mirrors `sessionRunManager.server.ts`.
* Tenant-scoped because `Session.currentRunId` is a no-FK pointer.
*/
export async function serializeSessionWithFriendlyRunId(
session: Session,
runStore: RunStore = defaultRunStore
): Promise<SessionItem> {
const base = serializeSession(session);
if (!session.currentRunId) return base;
const run = await runStore.findRun(
{
id: session.currentRunId,
projectId: session.projectId,
runtimeEnvironmentId: session.runtimeEnvironmentId,
},
{ select: { friendlyId: true } }
);
return {
...base,
currentRunId: run?.friendlyId ?? null,
};
}
/**
* Batched form of {@link serializeSessionWithFriendlyRunId} for list
* endpoints: one `IN` lookup per page instead of N+1. `currentRunId` on
* the wire is always the public `run_*` friendlyId — the raw
* {@link serializeSession} form leaks the internal cuid, which customers
* can't use with `runs.retrieve(...)`.
*/
export async function serializeSessionsWithFriendlyRunIds(
sessions: Session[],
scope: { projectId: string; runtimeEnvironmentId: string },
runStore: RunStore = defaultRunStore
): Promise<SessionItem[]> {
const runIds = [
...new Set(sessions.map((s) => s.currentRunId).filter((id): id is string => !!id)),
];
// `runStore.findRuns` fans out across both stores under split (NEW + LEGACY
// replica merge) and is a plain `$replica` find when split is off. Tenant-
// scoped: `Session.currentRunId` is a no-FK pointer, so a stale id must never
// resolve a run in another env.
const runs =
runIds.length > 0
? await runStore.findRuns({
where: {
id: { in: runIds },
projectId: scope.projectId,
runtimeEnvironmentId: scope.runtimeEnvironmentId,
},
select: { id: true, friendlyId: true },
})
: [];
const friendlyIdByRunId = new Map(runs.map((run) => [run.id, run.friendlyId]));
return sessions.map((session) => ({
...serializeSession(session),
currentRunId: session.currentRunId
? (friendlyIdByRunId.get(session.currentRunId) ?? null)
: null,
}));
}
@@ -0,0 +1,283 @@
import {
type ElectricColumnType,
RUN_ELECTRIC_COLUMNS,
serializeRunRow,
} from "./electricStreamProtocol.server";
import { type RunHydrator, type RunListFilter, type RunListResolver } from "./runReader.server";
/**
* Dual-run shadow-compare: the client is always served the Electric response while this re-derives what
* the native backend would emit and diffs the two, to prove parity on real traffic before cutover. Checks
* serialization (semantic per-column compare, gated on same updatedAt so a changed row is "skew", not a
* divergence) and membership (emitted id-set, only on tag/batch initial snapshots). Pure but for the injected deps.
*/
export type ShadowFeed = "run" | "runs" | "batch";
type WireValue = Record<string, string | null>;
type ShapeMessage = {
key?: string;
value?: WireValue;
headers: { operation?: string; control?: string };
};
const COLUMN_BY_NAME = new Map(RUN_ELECTRIC_COLUMNS.map((column) => [column.name, column]));
export type ColumnDiff = {
runId: string;
column: string;
electric: string | null;
native: string | null;
};
export type ShadowCompareOutcome = {
feed: ShadowFeed;
/** Runs whose every emitted column matched (same-version). */
serializationMatched: number;
/** Runs with at least one semantic column divergence (same-version). */
serializationDiverged: number;
/** Runs that changed between Electric's emit and our refetch (not a divergence). */
serializationSkew: number;
/** Per-column divergences (capped) for logging. */
diffs: ColumnDiff[];
/** Set membership (tag/batch initial snapshot only). undefined when not checked. */
membershipMatch?: boolean;
missingInNative?: string[];
extraInNative?: string[];
};
export type ShadowCompareInput = {
feed: ShadowFeed;
/** The served Electric response body (a JSON array of messages, or "" / "[]"). */
electricBody: string;
environment: { id: string };
skipColumns: string[];
/** True when this was an initial snapshot request (offset=-1); enables membership compare. */
isInitialSnapshot: boolean;
/** When set (tag/batch initial snapshot), compare the resolved id-set. */
membershipFilter?: RunListFilter;
};
const MAX_DIFFS = 20;
export class RealtimeShadowComparator {
constructor(
private readonly options: { runReader: RunHydrator; runListResolver: RunListResolver }
) {}
async compare(input: ShadowCompareInput): Promise<ShadowCompareOutcome> {
const messages = parseBody(input.electricBody);
const changes = messages.filter(
(m): m is ShapeMessage & { value: WireValue } =>
typeof m.headers?.operation === "string" && !!m.value && m.headers.operation !== "delete"
);
const outcome: ShadowCompareOutcome = {
feed: input.feed,
serializationMatched: 0,
serializationDiverged: 0,
serializationSkew: 0,
diffs: [],
};
// Bulk-hydrate every emitted run in one query rather than a per-message round
// trip, so shadow mode doesn't inflate the very replica load it's measuring.
const emittedIds = changes
.map((m) => m.value.id)
.filter((id): id is string => typeof id === "string");
const hydrated = await this.options.runReader.hydrateByIds(input.environment.id, emittedIds);
const rowsById = new Map(hydrated.map((row) => [row.id, row]));
for (const message of changes) {
const runId = message.value.id ?? undefined;
if (!runId) {
continue;
}
const row = rowsById.get(runId);
if (!row) {
// Run no longer readable (deleted / replica miss). Not a serialization divergence.
outcome.serializationSkew++;
continue;
}
const nativeValue = serializeRunRow(row, input.skipColumns);
// Only compare rows at the same version; otherwise the row advanced between
// Electric's emit and our refetch (timing skew, not a divergence).
if (!sameInstant(message.value.updatedAt, nativeValue.updatedAt)) {
outcome.serializationSkew++;
continue;
}
let rowDiverged = false;
for (const [column, electricRaw] of Object.entries(message.value)) {
const meta = COLUMN_BY_NAME.get(column);
if (!meta) {
continue;
}
const nativeRaw = nativeValue[column] ?? null;
if (!valuesEqual(electricRaw, nativeRaw, meta.type, meta.dims, column)) {
rowDiverged = true;
if (outcome.diffs.length < MAX_DIFFS) {
outcome.diffs.push({ runId, column, electric: electricRaw, native: nativeRaw });
}
}
}
if (rowDiverged) {
outcome.serializationDiverged++;
} else {
outcome.serializationMatched++;
}
}
if (input.isInitialSnapshot && input.membershipFilter) {
const electricIds = new Set(
changes.map((m) => m.value.id).filter((id): id is string => typeof id === "string")
);
const nativeIds = new Set(
await this.options.runListResolver.resolveMatchingRunIds(input.membershipFilter)
);
outcome.missingInNative = [...electricIds].filter((id) => !nativeIds.has(id));
outcome.extraInNative = [...nativeIds].filter((id) => !electricIds.has(id));
outcome.membershipMatch =
outcome.missingInNative.length === 0 && outcome.extraInNative.length === 0;
}
return outcome;
}
}
function parseBody(body: string): ShapeMessage[] {
const text = body.trim();
if (!text) {
return [];
}
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? (parsed as ShapeMessage[]) : [];
} catch {
return [];
}
}
/** Status carries a known legacy rewrite (DEQUEUED -> EXECUTING) applied equally to
* both paths for non-current API versions; treat them as equivalent. */
function normalizeStatus(value: string): string {
return value === "DEQUEUED" ? "EXECUTING" : value;
}
function sameInstant(a: string | null | undefined, b: string | null | undefined): boolean {
if (a == null || b == null) {
return a == null && b == null;
}
// Mirror the SDK's RawShapeDate (`new Date(val + "Z")`).
return new Date(`${a}Z`).getTime() === new Date(`${b}Z`).getTime();
}
function valuesEqual(
electricRaw: string | null,
nativeRaw: string | null,
type: ElectricColumnType,
dims: number | undefined,
column: string
): boolean {
if (electricRaw == null || nativeRaw == null) {
return electricRaw == null && nativeRaw == null;
}
if (dims && dims > 0) {
return arraysEqual(parsePgTextArray(electricRaw), parsePgTextArray(nativeRaw));
}
switch (type) {
case "timestamp":
return new Date(`${electricRaw}Z`).getTime() === new Date(`${nativeRaw}Z`).getTime();
case "bool":
return parseBool(electricRaw) === parseBool(nativeRaw);
case "int4":
case "int8":
case "float8":
return Number(electricRaw) === Number(nativeRaw);
case "jsonb":
return jsonEqual(electricRaw, nativeRaw);
case "text":
default:
if (column === "status") {
return normalizeStatus(electricRaw) === normalizeStatus(nativeRaw);
}
return electricRaw === nativeRaw;
}
}
function parseBool(value: string): boolean {
return value === "t" || value === "true";
}
function jsonEqual(a: string, b: string): boolean {
try {
return deepEqual(JSON.parse(a), JSON.parse(b));
} catch {
return a === b;
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
}
if (typeof a === "object" && typeof b === "object") {
const ak = Object.keys(a as object).sort();
const bk = Object.keys(b as object).sort();
return (
ak.length === bk.length &&
ak.every((k, i) => k === bk[i]) &&
ak.every((k) => deepEqual((a as any)[k], (b as any)[k]))
);
}
return false;
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/** Parse a Postgres text-array literal (`{"a","b"}` / `{}`). Mirrors the client's pgArrayParser. */
function parsePgTextArray(literal: string): string[] {
if (literal === "{}" || literal === "") {
return [];
}
const inner = literal.startsWith("{") && literal.endsWith("}") ? literal.slice(1, -1) : literal;
const result: string[] = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '"') {
i++;
let s = "";
while (i < inner.length && inner[i] !== '"') {
if (inner[i] === "\\") {
i++;
}
s += inner[i];
i++;
}
result.push(s);
i++;
if (inner[i] === ",") i++;
} else {
let s = "";
while (i < inner.length && inner[i] !== ",") {
s += inner[i];
i++;
}
result.push(s);
if (inner[i] === ",") i++;
}
}
return result;
}
@@ -0,0 +1,188 @@
import type { API_VERSIONS } from "~/api/versions";
import { logger } from "../logger.server";
import {
type RealtimeEnvironment,
type RealtimeRequestOptions,
type RealtimeRunsParams,
} from "../realtimeClient.server";
import { RESERVED_COLUMNS } from "./electricStreamProtocol.server";
import {
type RealtimeListEnvironment,
type RealtimeStreamClient,
} from "./nativeRealtimeClient.server";
import { type RunListFilter } from "./runReader.server";
import {
type RealtimeShadowComparator,
type ShadowCompareOutcome,
type ShadowFeed,
} from "./shadowCompare.server";
export type ShadowRealtimeClientOptions = {
/** The path actually served to the client (Electric). */
electric: RealtimeStreamClient;
comparator: RealtimeShadowComparator;
/** createdAt window (ms) used to resolve tag-list membership for the compare. */
maximumCreatedAtFilterAgeMs: number;
/** Cap for the membership resolve. */
maxListResults: number;
/** Metrics sink for compare outcomes. */
onOutcome?: (outcome: ShadowCompareOutcome) => void;
};
/** Transparent wrapper that serves the Electric response unchanged and, in the background (fire-and-forget), diffs what the native backend would emit. */
export class ShadowRealtimeClient implements RealtimeStreamClient {
constructor(private readonly options: ShadowRealtimeClientOptions) {}
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamRun(
url,
environment,
runId,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("run", response, url, environment, requestOptions);
return response;
}
async streamRuns(
url: URL | string,
environment: RealtimeListEnvironment,
params: RealtimeRunsParams,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamRuns(
url,
environment,
params,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("runs", response, url, environment, requestOptions, { tags: params.tags ?? [] });
return response;
}
async streamBatch(
url: URL | string,
environment: RealtimeListEnvironment,
batchId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamBatch(
url,
environment,
batchId,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("batch", response, url, environment, requestOptions, { batchId });
return response;
}
/** Fire-and-forget; never blocks the served response, never throws into the request. */
#shadow(
feed: ShadowFeed,
electricResponse: Response,
url: URL | string,
environment: RealtimeEnvironment & { projectId?: string },
requestOptions?: RealtimeRequestOptions,
membership?: { tags?: string[]; batchId?: string }
): void {
// Clone synchronously before the client consumes the body.
let bodyClone: Response;
try {
if (electricResponse.status !== 200) {
return;
}
bodyClone = electricResponse.clone();
} catch {
return;
}
void this.#runShadow(feed, bodyClone, url, environment, requestOptions, membership).catch(
(error) => logger.debug("[shadowRealtime] compare failed", { feed, error })
);
}
async #runShadow(
feed: ShadowFeed,
bodyClone: Response,
url: URL | string,
environment: RealtimeEnvironment & { projectId?: string },
requestOptions: RealtimeRequestOptions | undefined,
membership: { tags?: string[]; batchId?: string } | undefined
): Promise<void> {
const $url = new URL(url.toString());
const offset = $url.searchParams.get("offset") ?? "-1";
const handle = $url.searchParams.get("handle") ?? $url.searchParams.get("shape_id");
const isInitialSnapshot = offset === "-1" || !handle;
const skipColumns = resolveSkipColumns($url, requestOptions);
const electricBody = await bodyClone.text();
let membershipFilter: RunListFilter | undefined;
if (isInitialSnapshot && membership && environment.projectId) {
membershipFilter = {
organizationId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
tags: membership.tags,
batchId: membership.batchId,
createdAtAfter: membership.batchId
? undefined
: new Date(Date.now() - this.options.maximumCreatedAtFilterAgeMs),
limit: this.options.maxListResults,
};
}
const outcome = await this.options.comparator.compare({
feed,
electricBody,
environment: { id: environment.id },
skipColumns,
isInitialSnapshot,
membershipFilter,
});
this.options.onOutcome?.(outcome);
if (outcome.serializationDiverged > 0 || outcome.membershipMatch === false) {
logger.warn("[shadowRealtime] divergence detected", {
feed,
serializationDiverged: outcome.serializationDiverged,
serializationMatched: outcome.serializationMatched,
serializationSkew: outcome.serializationSkew,
membershipMatch: outcome.membershipMatch,
missingInNative: outcome.missingInNative?.slice(0, 20),
extraInNative: outcome.extraInNative?.slice(0, 20),
// Log only which run/column diverged, never the raw cell values — they can
// include run payload/output/metadata and must not leak into logs.
diffs: outcome.diffs.map(({ runId, column }) => ({ runId, column })),
});
}
}
}
function resolveSkipColumns(url: URL, requestOptions?: RealtimeRequestOptions): string[] {
const raw = requestOptions?.skipColumns ?? url.searchParams.get("skipColumns")?.split(",") ?? [];
return raw.map((c) => c.trim()).filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c));
}
@@ -0,0 +1,69 @@
import { getMeter } from "@internal/tracing";
import { $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { realtimeClient } from "../realtimeClientGlobal.server";
import { ClickHouseRunListResolver } from "./clickHouseRunListResolver.server";
import { RunHydrator } from "./runReader.server";
import { RealtimeShadowComparator } from "./shadowCompare.server";
import { ShadowRealtimeClient } from "./shadowRealtimeClient.server";
/**
* Process-singleton wiring for the shadow-compare client. Only constructed
* when an org's `realtimeBackend` flag is set to "shadow".
*/
function initializeShadowRealtimeClient(): ShadowRealtimeClient {
const compares = getMeter("realtime-shadow").createCounter("realtime_shadow.compares", {
description:
"Dual-run shadow-compare outcomes (Electric vs native). kind=serialization|membership, result=match|diverge|skew.",
});
const comparator = new RealtimeShadowComparator({
runReader: new RunHydrator({ replica: $replica, runStore }),
runListResolver: new ClickHouseRunListResolver({
getClickhouse: (organizationId) =>
clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"),
prisma: $replica,
}),
});
return new ShadowRealtimeClient({
electric: realtimeClient,
comparator,
maximumCreatedAtFilterAgeMs: env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS,
maxListResults: env.REALTIME_BACKEND_NATIVE_MAX_LIST_RESULTS,
onOutcome: (outcome) => {
const { feed } = outcome;
if (outcome.serializationMatched) {
compares.add(outcome.serializationMatched, {
feed,
kind: "serialization",
result: "match",
});
}
if (outcome.serializationDiverged) {
compares.add(outcome.serializationDiverged, {
feed,
kind: "serialization",
result: "diverge",
});
}
if (outcome.serializationSkew) {
compares.add(outcome.serializationSkew, { feed, kind: "serialization", result: "skew" });
}
if (outcome.membershipMatch !== undefined) {
compares.add(1, {
feed,
kind: "membership",
result: outcome.membershipMatch ? "match" : "diverge",
});
}
},
});
}
export function getShadowRealtimeClient(): ShadowRealtimeClient {
return singleton("shadowRealtimeClient", initializeShadowRealtimeClient);
}
@@ -0,0 +1,246 @@
/**
* Per-org S2 basin provisioning. Gated by
* `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED`: when off, all orgs share
* `REALTIME_STREAMS_S2_BASIN` and this module no-ops.
*
* Pure retention-string in / S2-call out. Plan vocabulary lives in the
* cloud billing app, which calls into the admin sync route to drive
* provisioning + reconfiguration.
*/
import type { PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { parseDuration } from "./duration.server";
export function isPerOrgBasinsEnabled(): boolean {
return env.REALTIME_STREAMS_PER_ORG_BASINS_ENABLED === "true";
}
export function defaultRetention(): string {
return env.REALTIME_STREAMS_BASIN_DEFAULT_RETENTION;
}
// Org id is a cuid — fixed-length and stable, so the basin name is
// collision-free without truncation. Slugs are user-editable and would
// drift.
export function basinNameForOrg(org: { id: string }): string {
const prefix = env.REALTIME_STREAMS_BASIN_NAME_PREFIX;
const envName = env.REALTIME_STREAMS_BASIN_NAME_ENV;
return `${prefix}-${envName}-org-${org.id}`;
}
type ProvisionInput = {
id: string;
retention?: string;
streamBasinName: string | null | undefined;
};
type ProvisionResult =
| { kind: "skipped"; reason: "feature-disabled" | "already-provisioned"; basin: string | null }
| { kind: "provisioned"; basin: string; retention: string };
// Idempotent. Treats S2 409 as success (race with another caller, or
// previous run that crashed after S2 ack but before the column write).
export async function provisionBasinForOrg(
org: ProvisionInput,
prismaClient: PrismaClientOrTransaction = prisma
): Promise<ProvisionResult> {
if (!isPerOrgBasinsEnabled()) {
return { kind: "skipped", reason: "feature-disabled", basin: null };
}
if (org.streamBasinName) {
return { kind: "skipped", reason: "already-provisioned", basin: org.streamBasinName };
}
const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN;
if (!accessToken) {
throw new Error(
"REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true"
);
}
const basin = basinNameForOrg(org);
const retention = org.retention ?? defaultRetention();
await s2CreateBasin(basin, {
accessToken,
retentionPolicy: retention,
storageClass: env.REALTIME_STREAMS_BASIN_STORAGE_CLASS,
deleteOnEmptyMinAge: env.REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE,
});
await prismaClient.organization.update({
where: { id: org.id },
data: { streamBasinName: basin },
});
// streamBasinName is embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(org.id);
logger.info("[streamBasinProvisioner] provisioned basin for org", {
orgId: org.id,
basin,
retention,
});
return { kind: "provisioned", basin, retention };
}
export async function reconfigureBasinForOrg(orgId: string, retention: string): Promise<void> {
if (!isPerOrgBasinsEnabled()) return;
const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN;
if (!accessToken) {
throw new Error(
"REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true"
);
}
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org?.streamBasinName) return;
await s2ReconfigureBasin(org.streamBasinName, { accessToken, retentionPolicy: retention });
logger.info("[streamBasinProvisioner] reconfigured basin retention", {
orgId,
basin: org.streamBasinName,
retention,
});
}
type EnsureResult =
| { kind: "skipped"; reason: "feature-disabled" | "org-not-found" }
| { kind: "provisioned"; basin: string; retention: string }
| { kind: "reconfigured"; basin: string; retention: string };
// Idempotent: provisions if the org has no basin, PATCHes retention if
// it does. The single entrypoint the cloud billing app drives — both
// for the live plan-change path and the bulk backfill.
export async function ensureBasinForOrg(orgId: string, retention: string): Promise<EnsureResult> {
if (!isPerOrgBasinsEnabled()) {
return { kind: "skipped", reason: "feature-disabled" };
}
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org) return { kind: "skipped", reason: "org-not-found" };
if (!org.streamBasinName) {
const result = await provisionBasinForOrg({ id: org.id, streamBasinName: null, retention });
if (result.kind === "provisioned") {
return { kind: "provisioned", basin: result.basin, retention: result.retention };
}
return { kind: "skipped", reason: "feature-disabled" };
}
await reconfigureBasinForOrg(org.id, retention);
return { kind: "reconfigured", basin: org.streamBasinName, retention };
}
// Inverse of ensureBasinForOrg: nulls the column so future runs/sessions
// land in the shared global basin. The S2 basin lingers; existing streams
// age out on their original retention.
export async function deprovisionBasinForOrg(
orgId: string
): Promise<{ kind: "deprovisioned" } | { kind: "skipped"; reason: "no-basin" }> {
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org?.streamBasinName) return { kind: "skipped", reason: "no-basin" };
await prisma.organization.update({
where: { id: org.id },
data: { streamBasinName: null },
});
// streamBasinName is embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(org.id);
logger.info("[streamBasinProvisioner] deprovisioned basin for org", {
orgId,
previousBasin: org.streamBasinName,
});
return { kind: "deprovisioned" };
}
// S2 REST: POST /v1/basins to create, PATCH /v1/basins/{name} to
// reconfigure. Wire shape takes integer seconds; we accept human strings
// like "7d" / "1y" as env-var ergonomics and parse them here.
type CreateBasinOptions = {
accessToken: string;
retentionPolicy: string;
storageClass: "express" | "standard";
deleteOnEmptyMinAge: string;
};
async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins`;
const body = {
basin: name,
config: {
create_stream_on_append: true,
create_stream_on_read: true,
default_stream_config: {
storage_class: opts.storageClass,
retention_policy: { age: parseDuration(opts.retentionPolicy) },
delete_on_empty: { min_age_secs: parseDuration(opts.deleteOnEmptyMinAge) },
},
},
};
const res = await fetch(url, {
signal: AbortSignal.timeout(10_000),
method: "POST",
headers: {
Authorization: `Bearer ${opts.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// 409 = basin already exists; treat as success (idempotent).
if (res.ok || res.status === 409) return;
const text = await res.text().catch(() => "");
throw new Error(`S2 createBasin failed: ${res.status} ${res.statusText} ${text}`);
}
type ReconfigureBasinOptions = {
accessToken: string;
retentionPolicy: string;
};
async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`;
const body = {
default_stream_config: {
retention_policy: { age: parseDuration(opts.retentionPolicy) },
},
};
const res = await fetch(url, {
signal: AbortSignal.timeout(10_000),
method: "PATCH",
headers: {
Authorization: `Bearer ${opts.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return;
const text = await res.text().catch(() => "");
throw new Error(`S2 reconfigureBasin failed: ${res.status} ${res.statusText} ${text}`);
}
@@ -0,0 +1,63 @@
export type StreamRecord = {
data: string;
id: string;
seqNum: number;
/**
* S2 record headers, when the underlying backend is the v2 (S2) shape.
* Undefined or empty for run-scoped Redis streams. First-header empty-name
* is an S2 command record (trim/fence); the parser strips those before
* surfacing the record, so callers never see them.
*/
headers?: Array<[string, string]>;
};
// Interface for stream ingestion
export interface StreamIngestor {
initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }>;
ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response>;
appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void>;
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number>;
readRecords(runId: string, streamId: string, afterSeqNum?: number): Promise<StreamRecord[]>;
}
export type StreamResponseOptions = {
timeoutInSeconds?: number;
lastEventId?: string;
/**
* Session-stream-only. When `true`, the responder MAY peek the tail
* of `.out` and short-circuit to `wait=0` + `X-Session-Settled: true`
* if the last record is a terminal marker (a `trigger-control`
* `turn-complete` control record, ignoring any trailing S2 trim
* command record). Used by `TriggerChatTransport.reconnectToStream`
* on page reload.
*
* When absent/false, the responder keeps the unconditional long-poll
* behavior — required on the active send-a-message path where the
* peek would race the newly-triggered turn's first chunk.
*/
peekSettled?: boolean;
};
// Interface for stream response
export interface StreamResponder {
streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response>;
}
@@ -0,0 +1,33 @@
export class LineTransformStream extends TransformStream<string, string[]> {
private buffer = "";
constructor() {
super({
transform: (chunk, controller) => {
// Append the chunk to the buffer
this.buffer += chunk;
// Split on newlines
const lines = this.buffer.split("\n");
// The last element might be incomplete, hold it back in buffer
this.buffer = lines.pop() || "";
// Filter out empty or whitespace-only lines
const fullLines = lines.filter((line) => line.trim().length > 0);
// If we got any complete lines, emit them as an array
if (fullLines.length > 0) {
controller.enqueue(fullLines);
}
},
flush: (controller) => {
// On stream end, if there's leftover text, emit it as a single-element array
const trimmed = this.buffer.trim();
if (trimmed.length > 0) {
controller.enqueue([trimmed]);
}
},
});
}
}
@@ -0,0 +1,143 @@
import {
createCache,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
RedisCacheStore,
} from "@internal/cache";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
import { S2RealtimeStreams } from "./s2realtimeStreams.server";
import type { StreamIngestor, StreamResponder } from "./types";
function initializeRedisRealtimeStreams() {
return new RedisRealtimeStreams({
redis: {
port: env.REALTIME_STREAMS_REDIS_PORT,
host: env.REALTIME_STREAMS_REDIS_HOST,
username: env.REALTIME_STREAMS_REDIS_USERNAME,
password: env.REALTIME_STREAMS_REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REALTIME_STREAMS_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "tr:realtime:streams:",
},
inactivityTimeoutMs: env.REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS,
});
}
export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams);
/**
* Resolve a stream's basin. Precedence: run → session → org → global env.
* Pre-migration rows have `streamBasinName: null` and fall through to
* the global basin (where their streams actually live), so only pass
* `organization` when no run/session row exists at all — otherwise a
* null column would short-circuit to the org's *current* basin.
*/
export type StreamBasinContext = {
run?: { streamBasinName: string | null } | null;
session?: { streamBasinName: string | null } | null;
organization?: { streamBasinName: string | null } | null;
};
export function resolveStreamBasin(ctx: StreamBasinContext): string | undefined {
return (
ctx.run?.streamBasinName ??
ctx.session?.streamBasinName ??
ctx.organization?.streamBasinName ??
env.REALTIME_STREAMS_S2_BASIN ??
undefined
);
}
export function getRealtimeStreamInstance(
environment: AuthenticatedEnvironment,
streamVersion: string,
basinContext?: StreamBasinContext
): StreamIngestor & StreamResponder {
if (streamVersion === "v1") {
return v1RealtimeStreams;
}
const resolvedBasin = resolveStreamBasin(basinContext ?? {});
if (
resolvedBasin &&
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return new S2RealtimeStreams({
basin: resolvedBasin,
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
streamPrefix: streamPrefixFor(environment, resolvedBasin),
logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL,
flushIntervalMs: env.REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS,
maxRetries: env.REALTIME_STREAMS_S2_MAX_RETRIES,
s2WaitSeconds: env.REALTIME_STREAMS_S2_WAIT_SECONDS,
accessTokenExpirationInMs: env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS,
cache: s2RealtimeStreamsCache,
});
}
throw new Error("Realtime streams v2 is required for this run but S2 configuration is missing");
}
// Shared basin needs `org/{orgId}` to namespace; per-org basin already
// isolates so the segment drops.
function streamPrefixFor(environment: AuthenticatedEnvironment, basin: string): string {
const isPerOrgBasin = basin !== env.REALTIME_STREAMS_S2_BASIN;
const segments = isPerOrgBasin
? ["env", environment.slug, environment.id]
: ["org", environment.organization.id, "env", environment.slug, environment.id];
return segments.join("/");
}
export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" {
if (!streamVersion) {
return env.REALTIME_STREAMS_DEFAULT_VERSION;
}
if (
streamVersion === "v2" &&
env.REALTIME_STREAMS_S2_BASIN &&
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return "v2";
}
return "v1";
}
const s2RealtimeStreamsCache = singleton(
"s2RealtimeStreamsCache",
initializeS2RealtimeStreamsCache
);
function initializeS2RealtimeStreamsCache() {
const ctx = new DefaultStatefulContext();
const redisCacheStore = new RedisCacheStore({
name: "s2-realtime-streams-cache",
connection: {
port: env.REALTIME_STREAMS_REDIS_PORT,
host: env.REALTIME_STREAMS_REDIS_HOST,
username: env.REALTIME_STREAMS_REDIS_USERNAME,
password: env.REALTIME_STREAMS_REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REALTIME_STREAMS_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "s2-realtime-streams-cache:",
},
useModernCacheKeyBuilder: true,
});
const memoryStore = createLRUMemoryStore(5000);
return createCache({
accessToken: new Namespace<string>(ctx, {
stores: [memoryStore, redisCacheStore],
fresh: Math.floor(env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS / 2),
stale: Math.floor(env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS / 2 + 60_000),
}),
});
}
@@ -0,0 +1,614 @@
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { safeParseNaturalLanguageDurationAgo } from "@trigger.dev/core/v3/isomorphic";
import type { Callback, Result } from "ioredis";
import { randomUUID } from "node:crypto";
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
import { logger } from "./logger.server";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import type { Cache } from "@unkey/cache";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { createLRUMemoryStore } from "@internal/cache";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { env } from "~/env.server";
import type { API_VERSIONS } from "~/api/versions";
import { CURRENT_API_VERSION } from "~/api/versions";
export interface CachedLimitProvider {
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
}
const DEFAULT_ELECTRIC_COLUMNS = [
"id",
"taskIdentifier",
"createdAt",
"updatedAt",
"startedAt",
"delayUntil",
"queuedAt",
"expiredAt",
"completedAt",
"friendlyId",
"number",
"isTest",
"status",
"usageDurationMs",
"costInCents",
"baseCostInCents",
"ttl",
"payload",
"payloadType",
"metadata",
"metadataType",
"output",
"outputType",
"runTags",
"error",
"realtimeStreams",
];
const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
const RESERVED_SEARCH_PARAMS = ["createdAt", "tags", "skipColumns"];
export type RealtimeClientOptions = {
electricOrigin: string | string[];
redis: RedisWithClusterOptions;
cachedLimitProvider: CachedLimitProvider;
keyPrefix: string;
expiryTimeInSeconds?: number;
};
export type RealtimeEnvironment = {
id: string;
organizationId: string;
};
export type RealtimeRunsParams = {
tags?: string[];
createdAt?: string;
};
export type RealtimeRequestOptions = {
skipColumns?: string[];
};
export class RealtimeClient {
private redis: RedisClient;
private expiryTimeInSeconds: number;
private cachedLimitProvider: CachedLimitProvider;
private cache: Cache<{ createdAtFilter: string }>;
constructor(private options: RealtimeClientOptions) {
this.redis = createRedisClient("trigger:realtime", options.redis);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5; // default to 5 minutes
this.cachedLimitProvider = options.cachedLimitProvider;
this.#registerCommands();
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:realtime",
port: options.redis.port,
host: options.redis.host,
username: options.redis.username,
password: options.redis.password,
tlsDisabled: options.redis.tlsDisabled,
clusterMode: options.redis.clusterMode,
},
});
// This cache holds the limits fetched from the platform service
const cache = createCache({
createdAtFilter: new Namespace<string>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 60 * 24 * 7, // 1 week
stale: 60_000 * 60 * 24 * 14, // 2 weeks
}),
});
this.cache = cache;
}
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
return this.#streamRunsWhere(
url,
environment,
`id='${runId}'`,
apiVersion,
requestOptions,
clientVersion,
signal
);
}
async streamBatch(
url: URL | string,
environment: RealtimeEnvironment,
batchId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const whereClauses: string[] = [
`"runtimeEnvironmentId"='${environment.id}'`,
`"batchId"='${batchId}'`,
];
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(
url,
environment,
whereClause,
apiVersion,
requestOptions,
clientVersion,
signal
);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
if (params.tags) {
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
}
const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt);
if (createdAtFilter) {
whereClauses.push(`"createdAt" > '${createdAtFilter.toISOString()}'`);
}
const whereClause = whereClauses.join(" AND ");
const response = await this.#streamRunsWhere(
url,
environment,
whereClause,
apiVersion,
requestOptions,
clientVersion,
signal
);
if (createdAtFilter) {
const [setCreatedAtFilterError] = await tryCatch(
this.#setCreatedAtFilterFromResponse(response, createdAtFilter)
);
if (setCreatedAtFilterError) {
logger.error("[realtimeClient] Failed to set createdAt filter", {
error: setCreatedAtFilterError,
createdAtFilter,
responseHeaders: Object.fromEntries(response.headers.entries()),
responseStatus: response.status,
});
}
}
return response;
}
async #calculateCreatedAtFilter(url: URL | string, createdAt?: string) {
const duration = createdAt ?? "24h";
const $url = new URL(url.toString());
const shapeId = extractShapeId($url);
if (!shapeId) {
// This means we need to calculate the createdAt filter and store it in redis after we get back the response
const createdAtFilter = safeParseNaturalLanguageDurationAgo(duration);
// Validate that the createdAt filter is in the past, and not more than the maximum age in the past.
// if it's more than the maximum age in the past, just return the maximum age in the past Date
if (
createdAtFilter &&
createdAtFilter < new Date(Date.now() - env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS)
) {
return new Date(Date.now() - env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS);
}
return createdAtFilter;
} else {
// We need to get the createdAt filter value from redis, if there is none we need to return undefined
const [createdAtFilterError, createdAtFilter] = await tryCatch(
this.#getCreatedAtFilter(shapeId)
);
if (createdAtFilterError) {
logger.error("[realtimeClient] Failed to get createdAt filter", {
shapeId,
error: createdAtFilterError,
});
return;
}
return createdAtFilter;
}
}
async #getCreatedAtFilter(shapeId: string) {
const createdAtFilterCacheResult = await this.cache.createdAtFilter.get(shapeId);
if (createdAtFilterCacheResult.err) {
logger.error("[realtimeClient] Failed to get createdAt filter", {
shapeId,
error: createdAtFilterCacheResult.err,
});
return;
}
if (!createdAtFilterCacheResult.val) {
return;
}
return new Date(createdAtFilterCacheResult.val);
}
async #setCreatedAtFilterFromResponse(response: Response, createdAtFilter: Date) {
const shapeId = extractShapeIdFromResponse(response);
if (!shapeId) {
return;
}
await this.cache.createdAtFilter.set(shapeId, createdAtFilter.toISOString());
}
async #streamRunsWhere(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
) {
const electricUrl = this.#constructRunsElectricUrl(
url,
environment,
whereClause,
requestOptions,
clientVersion
);
return this.#performElectricRequest(
electricUrl,
environment,
apiVersion,
signal,
clientVersion
);
}
#constructRunsElectricUrl(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string
): URL {
const $url = new URL(url.toString());
const electricOrigin = this.#resolveElectricOrigin($url, whereClause, environment.id);
const electricUrl = new URL(`${electricOrigin}/v1/shape`);
// Copy over all the url search params to the electric url
$url.searchParams.forEach((value, key) => {
if (RESERVED_SEARCH_PARAMS.includes(key)) {
return;
}
electricUrl.searchParams.set(key, value);
});
electricUrl.searchParams.set("where", whereClause);
electricUrl.searchParams.set("table", 'public."TaskRun"');
if (!clientVersion) {
// If the client version is not provided, that means we're using an older client
// This means the client will be sending shape_id instead of handle
electricUrl.searchParams.set("handle", electricUrl.searchParams.get("shape_id") ?? "");
}
let skipColumns = getSkipColumns($url.searchParams, requestOptions);
if (skipColumns.length > 0) {
skipColumns = skipColumns.filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c));
electricUrl.searchParams.set(
"columns",
DEFAULT_ELECTRIC_COLUMNS.filter((c) => !skipColumns.includes(c))
.map((c) => `"${c}"`)
.join(",")
);
} else {
electricUrl.searchParams.set(
"columns",
DEFAULT_ELECTRIC_COLUMNS.map((c) => `"${c}"`).join(",")
);
}
return electricUrl;
}
async #performElectricRequest(
url: URL,
environment: RealtimeEnvironment,
apiVersion: API_VERSIONS,
signal?: AbortSignal,
clientVersion?: string
) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
const rewriteResponseHeaders: Record<string, string> = clientVersion
? {}
: { "electric-handle": "electric-shape-id", "electric-offset": "electric-chunk-last-offset" };
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return this.#doLongPollingFetch(url, apiVersion, signal, rewriteResponseHeaders);
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return this.#doLongPollingFetch(url, apiVersion, signal, rewriteResponseHeaders);
}
const requestId = randomUUID();
// We now need to wrap the longPollingFetch in a concurrency tracker
const concurrencyLimit = await this.cachedLimitProvider.getCachedLimit(
environment.organizationId,
100_000
);
if (!concurrencyLimit) {
logger.error("Failed to get concurrency limit", {
organizationId: environment.organizationId,
});
return json({ error: "Failed to get concurrency limit" }, { status: 500 });
}
logger.debug("[realtimeClient] increment and check", {
concurrencyLimit,
shapeId,
requestId,
environment: {
id: environment.id,
organizationId: environment.organizationId,
},
});
const canProceed = await this.#incrementAndCheck(environment.id, requestId, concurrencyLimit);
if (!canProceed) {
logger.debug("[realtimeClient] too many concurrent requests", {
requestId,
environmentId: environment.id,
});
return json({ error: "Too many concurrent requests" }, { status: 429 });
}
try {
// ... (rest of your existing code for the long polling request)
const response = await this.#doLongPollingFetch(
url,
apiVersion,
signal,
rewriteResponseHeaders
);
// If this is the initial request, the response.headers['electric-handle'] will be the shapeId
// And we may need to set the "createdAt" filter timestamp keyed by the shapeId
// Then in the next request, we will get the createdAt timestamp value via the shapeId and use it to filter the results
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
return response;
} catch (error) {
// Decrement the counter if the request fails
await this.#decrementConcurrency(environment.id, requestId);
throw error;
}
}
async #doLongPollingFetch(
url: URL,
apiVersion: API_VERSIONS,
signal?: AbortSignal,
rewriteResponseHeaders?: Record<string, string>
) {
if (apiVersion === CURRENT_API_VERSION) {
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
}
const response = await longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
return this.#rewriteResponseForNoneApiVersion(response);
}
async #rewriteResponseForNoneApiVersion(response: Response) {
// Get the raw response body
const responseBody = await response.text();
// Rewrite the response body
const rewrittenResponseBody = this.#rewriteResponseBodyForNoneApiVersion(responseBody);
// Return the rewritten response
return new Response(rewrittenResponseBody, {
status: response.status,
headers: response.headers,
});
}
// Rewrites "status":"DEQUEUED" to "status":"EXECUTING"
#rewriteResponseBodyForNoneApiVersion(responseBody: string) {
return responseBody.replace(/"status":"DEQUEUED"/g, '"status":"EXECUTING"');
}
async #incrementAndCheck(environmentId: string, requestId: string, limit: number) {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(), // expiry time
(now - this.expiryTimeInSeconds * 1000).toString(), // cutoff time
limit.toString()
);
return result === 1;
}
async #decrementConcurrency(environmentId: string, requestId: string) {
logger.debug("[realtimeClient] decrement", {
requestId,
environmentId,
});
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#resolveElectricOrigin(url: URL, whereClause: string, environmentId: string) {
if (typeof this.options.electricOrigin === "string") {
return this.options.electricOrigin;
}
const shardKey = this.#getShardKey(whereClause, environmentId);
const index = jumpHash(shardKey, this.options.electricOrigin.length);
const origin = this.options.electricOrigin[index] ?? this.options.electricOrigin[0];
logger.debug("[realtimeClient] resolveElectricOrigin", {
whereClause,
environmentId,
shardKey,
index,
electricOrigin: origin,
});
return origin;
}
#getShardKey(whereClause: string, environmentId: string) {
return [environmentId, whereClause].join(":");
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
-- Remove the request we just added
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
-- Return 1 to indicate success
return 1
`,
});
}
}
function extractShapeId(url: URL) {
return url.searchParams.get("handle") ?? url.searchParams.get("shape_id");
}
function extractShapeIdFromResponse(response: Response) {
return response.headers.get("electric-handle");
}
function isLiveRequestUrl(url: URL) {
return url.searchParams.has("live") && url.searchParams.get("live") === "true";
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
function getSkipColumns(searchParams: URLSearchParams, requestOptions?: RealtimeRequestOptions) {
if (requestOptions?.skipColumns) {
return requestOptions.skipColumns;
}
const skipColumnsRaw = searchParams.get("skipColumns");
if (skipColumnsRaw) {
return skipColumnsRaw.split(",").map((c) => c.trim());
}
return [];
}

Some files were not shown because too many files have changed in this diff Show More