8465ac5ac3
## What Wires the run-ops split into the webapp: database topology, environment flags, split-mode gating, and the control-plane resolver/cache layer that the run-store and run-engine seams from the previous PR plug into. - **DB topology & env** (`apps/webapp/app/db.server.ts`, `env.server.ts`, `entry.server.tsx`): adds the run-ops database clients/topology and the environment variables that configure and gate the split. - **runOpsMigration module** (new `apps/webapp/app/v3/runOpsMigration/`): the webapp-side machinery — `splitMode.server.ts`, `controlPlaneResolver.server.ts` + `controlPlaneCache.server.ts`, `readThrough.server.ts`, `crossSeamGuard.server.ts`, `distinctDbSentinel.server.ts`, id-minting helpers (`mintBatchFriendlyId`, `runOpsMintKind`, `resolveInheritedMintKind`), `runOpsCascadeCleanup.server.ts`, the split read gate, and route/unblock catalogs. - **Store/engine wiring** (`app/v3/runStore.server.ts`, `runEngine.server.ts`, `runEngineHandlers.server.ts` + new `runEngineHandlersShared.server.ts`): points the webapp's store/engine construction at the resolver, and factors shared handler logic out so both seams use one path. - **Read-path touch-ups**: `runtimeEnvironment.server.ts`, `eventRepository/index.server.ts`, `taskRunHeartbeatFailed.server.ts`, `engineVersion.server.ts` route their run/environment lookups read-through the resolver. - `413a94511` — interlocks split mode against the native realtime backend so the two aren't enabled in an incompatible combination (see `.server-changes/run-ops-split-realtime-interlock.md`). - `dc74c57fd` — drops the earlier "known-migrated" read layer; residency is determined by id-shape only. ## Why PR5 of the run-ops split stack. This is the webapp foundation layer: it stands up the DB topology, flags, and resolver/cache the rest of the stack depends on, and repoints webapp read paths through the resolver. Additive when the split is not enabled (existing single-DB behavior preserved behind flags); behavior-changing on the read-through paths and the realtime interlock. ## Tests New vitest coverage across `apps/webapp/test/` and colocated `*.server.test.ts` files: db topology, split mode, split read gate, cross-seam guard, mint cutover / flip latency, control-plane cache, control-plane resolver, distinct-db sentinel, read-through loaders (route loaders, run-detail loaders, `findEnvironmentFromRun`), and the run-engine handlers. Testcontainers-backed; no mocks. `pnpm-lock.yaml` synced for the two new webapp deps. ## Notes Draft, **stacked on #4116** (`runops/pr04-store-engine`). Review that first; this diff is against it. Server-change / changeset note to be added at stack-assembly time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
3.3 KiB
TypeScript
127 lines
3.3 KiB
TypeScript
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
|
import { prisma } from "~/db.server";
|
|
import { customAlphabet } from "nanoid";
|
|
import { RuntimeEnvironmentType } from "~/database-types";
|
|
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
|
|
|
const apiKeyId = customAlphabet(
|
|
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
12
|
|
);
|
|
|
|
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
|
|
|
|
type RegenerateAPIKeyInput = {
|
|
userId: string;
|
|
environmentId: string;
|
|
};
|
|
|
|
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
|
|
const environment = await prisma.runtimeEnvironment.findUnique({
|
|
where: {
|
|
id: environmentId,
|
|
},
|
|
include: {
|
|
organization: true,
|
|
project: true,
|
|
},
|
|
});
|
|
|
|
if (!environment) {
|
|
throw new Error("Environment does not exist");
|
|
}
|
|
|
|
// check if the user is part of the org
|
|
const organization = await prisma.organization.findFirst({
|
|
where: {
|
|
id: environment.organization.id,
|
|
members: { some: { userId } },
|
|
},
|
|
});
|
|
|
|
if (!organization) {
|
|
throw new Error("User does not have permission to regenerate API key");
|
|
}
|
|
|
|
// check if it is the user's dev environment
|
|
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
|
|
if (!environment.orgMemberId) {
|
|
throw new Error("User does not have permission to regenerate API key");
|
|
}
|
|
|
|
const orgMember = await prisma.orgMember.findFirst({
|
|
where: {
|
|
organizationId: organization.id,
|
|
userId: userId,
|
|
id: environment.orgMemberId,
|
|
},
|
|
});
|
|
|
|
if (!orgMember) {
|
|
throw new Error("User does not have permission to regenerate API key");
|
|
}
|
|
}
|
|
|
|
// generate and store new keys
|
|
const newApiKey = createApiKeyForEnv(environment.type);
|
|
const newPkApiKey = createPkApiKeyForEnv(environment.type);
|
|
|
|
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
|
|
|
|
const updatedEnviroment = await prisma.$transaction(async (tx) => {
|
|
await tx.revokedApiKey.create({
|
|
data: {
|
|
apiKey: environment.apiKey,
|
|
runtimeEnvironmentId: environment.id,
|
|
expiresAt: revokedApiKeyExpiresAt,
|
|
},
|
|
});
|
|
|
|
return tx.runtimeEnvironment.update({
|
|
data: {
|
|
apiKey: newApiKey,
|
|
pkApiKey: newPkApiKey,
|
|
},
|
|
where: {
|
|
id: environmentId,
|
|
},
|
|
});
|
|
});
|
|
|
|
// The env's apiKey changed in the control-plane; drop any cached copy.
|
|
controlPlaneResolver.invalidateEnvironment(environmentId);
|
|
|
|
return updatedEnviroment;
|
|
}
|
|
|
|
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
|
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
|
|
}
|
|
|
|
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
|
|
return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
|
|
}
|
|
|
|
export type EnvSlug = "dev" | "stg" | "prod" | "preview";
|
|
|
|
export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
|
|
switch (environmentType) {
|
|
case "DEVELOPMENT": {
|
|
return "dev";
|
|
}
|
|
case "PRODUCTION": {
|
|
return "prod";
|
|
}
|
|
case "STAGING": {
|
|
return "stg";
|
|
}
|
|
case "PREVIEW": {
|
|
return "preview";
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug {
|
|
return ["dev", "stg", "prod", "preview"].includes(maybeSlug);
|
|
}
|