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(["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 = {}; 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; } export type RunHydratorOptions = { /** A read-replica Prisma client (`$replica`). Always Postgres. */ replica: Pick; /** 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>(); readonly #cache: BoundedTtlCache; 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 { 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 { 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 { 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; } }