chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { type ClickhouseQueryBuilder } from "@internal/clickhouse";
|
||||
import parseDuration from "parse-duration";
|
||||
import {
|
||||
convertSessionListInputOptionsToFilterOptions,
|
||||
type FilterSessionsOptions,
|
||||
type ISessionsRepository,
|
||||
type ListSessionsOptions,
|
||||
type SessionListInputOptions,
|
||||
type SessionTagListOptions,
|
||||
type SessionsRepositoryOptions,
|
||||
} from "./sessionsRepository.server";
|
||||
|
||||
export class ClickHouseSessionsRepository implements ISessionsRepository {
|
||||
constructor(private readonly options: SessionsRepositoryOptions) {}
|
||||
|
||||
get name() {
|
||||
return "clickhouse";
|
||||
}
|
||||
|
||||
async listSessionIds(options: ListSessionsOptions): Promise<string[]> {
|
||||
const queryBuilder = this.options.clickhouse.sessions.queryBuilder();
|
||||
applySessionFiltersToQueryBuilder(
|
||||
queryBuilder,
|
||||
convertSessionListInputOptionsToFilterOptions(options)
|
||||
);
|
||||
|
||||
if (options.page.cursor) {
|
||||
if (options.page.direction === "forward" || !options.page.direction) {
|
||||
queryBuilder
|
||||
.where("session_id < {sessionId: String}", { sessionId: options.page.cursor })
|
||||
.orderBy("created_at DESC, session_id DESC")
|
||||
.limit(options.page.size + 1);
|
||||
} else {
|
||||
queryBuilder
|
||||
.where("session_id > {sessionId: String}", { sessionId: options.page.cursor })
|
||||
.orderBy("created_at ASC, session_id ASC")
|
||||
.limit(options.page.size + 1);
|
||||
}
|
||||
} else {
|
||||
queryBuilder.orderBy("created_at DESC, session_id DESC").limit(options.page.size + 1);
|
||||
}
|
||||
|
||||
const [queryError, result] = await queryBuilder.execute();
|
||||
if (queryError) throw queryError;
|
||||
|
||||
return result.map((row) => row.session_id);
|
||||
}
|
||||
|
||||
async listSessions(options: ListSessionsOptions) {
|
||||
const sessionIds = await this.listSessionIds(options);
|
||||
const hasMore = sessionIds.length > options.page.size;
|
||||
|
||||
let nextCursor: string | null = null;
|
||||
let previousCursor: string | null = null;
|
||||
|
||||
const direction = options.page.direction ?? "forward";
|
||||
switch (direction) {
|
||||
case "forward": {
|
||||
previousCursor = options.page.cursor ? (sessionIds.at(0) ?? null) : null;
|
||||
if (hasMore) {
|
||||
nextCursor = sessionIds[options.page.size - 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "backward": {
|
||||
const reversed = [...sessionIds].reverse();
|
||||
if (hasMore) {
|
||||
previousCursor = reversed.at(1) ?? null;
|
||||
nextCursor = reversed.at(options.page.size) ?? null;
|
||||
} else {
|
||||
nextCursor = reversed.at(options.page.size - 1) ?? null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Both directions slice the first `size` IDs: the `size+1`th item is
|
||||
// the sentinel proving another page exists (hasMore), not part of the
|
||||
// page content. Backward queries sort ASC (items closest to the cursor
|
||||
// first), so `[0..size)` is still the legitimate window and the last
|
||||
// element is the sentinel — identical to the forward case.
|
||||
const idsToReturn = sessionIds.slice(0, options.page.size);
|
||||
|
||||
let sessions = await this.options.prisma.session.findMany({
|
||||
where: {
|
||||
id: { in: idsToReturn },
|
||||
runtimeEnvironmentId: options.environmentId,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
externalId: true,
|
||||
type: true,
|
||||
taskIdentifier: true,
|
||||
isTest: true,
|
||||
tags: true,
|
||||
metadata: true,
|
||||
closedAt: true,
|
||||
closedReason: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
runtimeEnvironmentId: true,
|
||||
currentRunId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ClickHouse is slightly delayed; narrow by derived status in-memory to
|
||||
// catch recent Postgres writes that haven't replicated yet.
|
||||
if (options.statuses && options.statuses.length > 0) {
|
||||
const wanted = new Set(options.statuses);
|
||||
const now = Date.now();
|
||||
sessions = sessions.filter((s) => {
|
||||
const status =
|
||||
s.closedAt != null
|
||||
? "CLOSED"
|
||||
: s.expiresAt != null && s.expiresAt.getTime() < now
|
||||
? "EXPIRED"
|
||||
: "ACTIVE";
|
||||
return wanted.has(status);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
pagination: { nextCursor, previousCursor },
|
||||
};
|
||||
}
|
||||
|
||||
async countSessions(options: SessionListInputOptions): Promise<number> {
|
||||
const queryBuilder = this.options.clickhouse.sessions.countQueryBuilder();
|
||||
applySessionFiltersToQueryBuilder(
|
||||
queryBuilder,
|
||||
convertSessionListInputOptionsToFilterOptions(options)
|
||||
);
|
||||
|
||||
const [queryError, result] = await queryBuilder.execute();
|
||||
if (queryError) throw queryError;
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("No count rows returned");
|
||||
}
|
||||
return result[0].count;
|
||||
}
|
||||
|
||||
async listTags(options: SessionTagListOptions) {
|
||||
const queryBuilder = this.options.clickhouse.sessions
|
||||
.tagQueryBuilder()
|
||||
.where("organization_id = {organizationId: String}", {
|
||||
organizationId: options.organizationId,
|
||||
})
|
||||
.where("project_id = {projectId: String}", { projectId: options.projectId })
|
||||
.where("environment_id = {environmentId: String}", {
|
||||
environmentId: options.environmentId,
|
||||
});
|
||||
|
||||
const periodMs = options.period ? (parseDuration(options.period) ?? undefined) : undefined;
|
||||
if (periodMs) {
|
||||
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
|
||||
period: new Date(Date.now() - periodMs).getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.from) {
|
||||
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
|
||||
from: options.from,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.to) {
|
||||
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", {
|
||||
to: options.to,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.query && options.query.trim().length > 0) {
|
||||
queryBuilder.where("positionCaseInsensitiveUTF8(tag, {query: String}) > 0", {
|
||||
query: options.query,
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.orderBy("tag ASC").limit(options.limit);
|
||||
|
||||
const [queryError, result] = await queryBuilder.execute();
|
||||
if (queryError) throw queryError;
|
||||
|
||||
return { tags: result.map((row) => row.tag) };
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionFiltersToQueryBuilder<T>(
|
||||
queryBuilder: ClickhouseQueryBuilder<T>,
|
||||
options: FilterSessionsOptions
|
||||
) {
|
||||
queryBuilder
|
||||
.where("organization_id = {organizationId: String}", {
|
||||
organizationId: options.organizationId,
|
||||
})
|
||||
.where("project_id = {projectId: String}", { projectId: options.projectId })
|
||||
.where("environment_id = {environmentId: String}", { environmentId: options.environmentId });
|
||||
|
||||
if (options.types && options.types.length > 0) {
|
||||
queryBuilder.where("type IN {types: Array(String)}", { types: options.types });
|
||||
}
|
||||
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
queryBuilder.where("hasAny(tags, {tags: Array(String)})", { tags: options.tags });
|
||||
}
|
||||
|
||||
if (options.taskIdentifiers && options.taskIdentifiers.length > 0) {
|
||||
queryBuilder.where("task_identifier IN {taskIdentifiers: Array(String)}", {
|
||||
taskIdentifiers: options.taskIdentifiers,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.externalId) {
|
||||
queryBuilder.where("external_id = {externalId: String}", { externalId: options.externalId });
|
||||
}
|
||||
|
||||
if (options.statuses && options.statuses.length > 0) {
|
||||
const conditions: string[] = [];
|
||||
if (options.statuses.includes("ACTIVE")) {
|
||||
conditions.push("(closed_at IS NULL AND (expires_at IS NULL OR expires_at > now64(3)))");
|
||||
}
|
||||
if (options.statuses.includes("CLOSED")) {
|
||||
conditions.push("closed_at IS NOT NULL");
|
||||
}
|
||||
if (options.statuses.includes("EXPIRED")) {
|
||||
conditions.push("(closed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= now64(3))");
|
||||
}
|
||||
if (conditions.length > 0) {
|
||||
queryBuilder.where(`(${conditions.join(" OR ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.period) {
|
||||
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
|
||||
period: new Date(Date.now() - options.period).getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.from) {
|
||||
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
|
||||
from: options.from,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.to) {
|
||||
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", {
|
||||
to: options.to,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { type ClickHouse } from "@internal/clickhouse";
|
||||
import { type Tracer } from "@internal/tracing";
|
||||
import { type Logger, type LogLevel } from "@trigger.dev/core/logger";
|
||||
import { type Prisma } from "@trigger.dev/database";
|
||||
import parseDuration from "parse-duration";
|
||||
import { z } from "zod";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { ClickHouseSessionsRepository } from "./clickhouseSessionsRepository.server";
|
||||
|
||||
export type SessionsRepositoryOptions = {
|
||||
clickhouse: ClickHouse;
|
||||
prisma: PrismaClientOrTransaction;
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
tracer?: Tracer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derived status values — `Session` rows don't have a stored status column.
|
||||
* `ACTIVE` is the base state; `CLOSED` means `closedAt` is set; `EXPIRED`
|
||||
* means `expiresAt` has passed.
|
||||
*/
|
||||
export const SessionStatus = z.enum(["ACTIVE", "CLOSED", "EXPIRED"]);
|
||||
export type SessionStatus = z.infer<typeof SessionStatus>;
|
||||
|
||||
/**
|
||||
* Legacy marker tag for sessions created from the Test/playground before the
|
||||
* `Session.isTest` boolean existed. New sessions set `isTest` instead; this tag
|
||||
* is hidden from the Tags display so it doesn't surface on pre-isTest rows.
|
||||
*/
|
||||
export const LEGACY_PLAYGROUND_TAG = "playground";
|
||||
|
||||
const SessionListInputOptionsSchema = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
// filters
|
||||
types: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
taskIdentifiers: z.array(z.string()).optional(),
|
||||
externalId: z.string().optional(),
|
||||
statuses: z.array(SessionStatus).optional(),
|
||||
period: z.string().optional(),
|
||||
from: z.number().optional(),
|
||||
to: z.number().optional(),
|
||||
});
|
||||
|
||||
export type SessionListInputOptions = z.infer<typeof SessionListInputOptionsSchema>;
|
||||
export type SessionListInputFilters = Omit<
|
||||
SessionListInputOptions,
|
||||
"organizationId" | "projectId" | "environmentId"
|
||||
>;
|
||||
|
||||
export type FilterSessionsOptions = Omit<SessionListInputOptions, "period"> & {
|
||||
/** period converted to milliseconds duration */
|
||||
period: number | undefined;
|
||||
};
|
||||
|
||||
type Pagination = {
|
||||
page: {
|
||||
size: number;
|
||||
cursor?: string;
|
||||
direction?: "forward" | "backward";
|
||||
};
|
||||
};
|
||||
|
||||
export type ListSessionsOptions = SessionListInputOptions & Pagination;
|
||||
|
||||
type OffsetPagination = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type SessionTagListOptions = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
/** Case-insensitive substring match on the tag name */
|
||||
query?: string;
|
||||
} & OffsetPagination;
|
||||
|
||||
export type SessionTagList = {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type ListedSession = Prisma.SessionGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
friendlyId: true;
|
||||
externalId: true;
|
||||
type: true;
|
||||
taskIdentifier: true;
|
||||
isTest: true;
|
||||
tags: true;
|
||||
metadata: true;
|
||||
closedAt: true;
|
||||
closedReason: true;
|
||||
expiresAt: true;
|
||||
createdAt: true;
|
||||
updatedAt: true;
|
||||
runtimeEnvironmentId: true;
|
||||
currentRunId: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ISessionsRepository = {
|
||||
name: string;
|
||||
listSessionIds(options: ListSessionsOptions): Promise<string[]>;
|
||||
listSessions(options: ListSessionsOptions): Promise<{
|
||||
sessions: ListedSession[];
|
||||
pagination: {
|
||||
nextCursor: string | null;
|
||||
previousCursor: string | null;
|
||||
};
|
||||
}>;
|
||||
countSessions(options: SessionListInputOptions): Promise<number>;
|
||||
listTags(options: SessionTagListOptions): Promise<SessionTagList>;
|
||||
};
|
||||
|
||||
export class SessionsRepository implements ISessionsRepository {
|
||||
private readonly clickHouseSessionsRepository: ClickHouseSessionsRepository;
|
||||
|
||||
constructor(private readonly options: SessionsRepositoryOptions) {
|
||||
this.clickHouseSessionsRepository = new ClickHouseSessionsRepository(options);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "sessionsRepository";
|
||||
}
|
||||
|
||||
async listSessionIds(options: ListSessionsOptions): Promise<string[]> {
|
||||
return startActiveSpan(
|
||||
"sessionsRepository.listSessionIds",
|
||||
async () => this.clickHouseSessionsRepository.listSessionIds(options),
|
||||
{
|
||||
attributes: {
|
||||
"repository.name": "clickhouse",
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
environmentId: options.environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async listSessions(options: ListSessionsOptions) {
|
||||
return startActiveSpan(
|
||||
"sessionsRepository.listSessions",
|
||||
async () => this.clickHouseSessionsRepository.listSessions(options),
|
||||
{
|
||||
attributes: {
|
||||
"repository.name": "clickhouse",
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
environmentId: options.environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async countSessions(options: SessionListInputOptions) {
|
||||
return startActiveSpan(
|
||||
"sessionsRepository.countSessions",
|
||||
async () => this.clickHouseSessionsRepository.countSessions(options),
|
||||
{
|
||||
attributes: {
|
||||
"repository.name": "clickhouse",
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
environmentId: options.environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async listTags(options: SessionTagListOptions) {
|
||||
return startActiveSpan(
|
||||
"sessionsRepository.listTags",
|
||||
async () => this.clickHouseSessionsRepository.listTags(options),
|
||||
{
|
||||
attributes: {
|
||||
"repository.name": "clickhouse",
|
||||
organizationId: options.organizationId,
|
||||
projectId: options.projectId,
|
||||
environmentId: options.environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSessionListInputOptions(data: unknown): SessionListInputOptions {
|
||||
return SessionListInputOptionsSchema.parse(data);
|
||||
}
|
||||
|
||||
export function convertSessionListInputOptionsToFilterOptions(
|
||||
options: SessionListInputOptions
|
||||
): FilterSessionsOptions {
|
||||
return {
|
||||
...options,
|
||||
period: options.period ? (parseDuration(options.period) ?? undefined) : undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user