195 lines
7.6 KiB
TypeScript
195 lines
7.6 KiB
TypeScript
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,
|
|
}));
|
|
}
|