Merge branch 'main' into fix/waitpoint-completion-db-error-mislabel

This commit is contained in:
Daniel Sutton
2026-07-17 16:37:03 +01:00
committed by GitHub
125 changed files with 4713 additions and 943 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add `defaultRegion` to the project GET and list API responses; null when unset.
+3 -1
View File
@@ -14,10 +14,12 @@ area: webapp
type: fix
---
Brief description of what changed and why.
Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically.
EOF
```
- **area**: `webapp` | `supervisor`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
The body ships **verbatim in user-facing release notes**. Keep it to 12 short sentences, non-technical, written for a dashboard user: describe what changed for them, never the implementation (no header names, endpoints, middleware, storage mechanisms, internal tools). See `.server-changes/README.md` for full guidance.
+2 -2
View File
@@ -6,7 +6,7 @@ LOGIN_ORIGIN=http://localhost:3030
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances
# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No
DIRECT_URL=${DATABASE_URL}
DIRECT_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
# Dedicated run-ops database (@internal/run-ops-database). Only needed to run prisma commands
# against it or to enable the run-ops split; start it with `docker compose --profile runops up`.
RUN_OPS_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/postgres?schema=public
@@ -166,4 +166,4 @@ POSTHOG_PROJECT_KEY=
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: Install dependencies
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: Install + build the CLI and the agent's deps
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🐳 Login to DockerHub
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
+7
View File
@@ -56,6 +56,7 @@ jobs:
- '.github/workflows/pr_checks.yml'
- '.github/workflows/unit-tests-webapp.yml'
- '.github/workflows/e2e-webapp.yml'
- '.github/workflows/runops-guard.yml'
- '.configs/**'
- 'package.json'
- 'pnpm-lock.yaml'
@@ -111,6 +112,11 @@ jobs:
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true'
uses: ./.github/workflows/typecheck.yml
runops-guard:
needs: changes
if: needs.changes.outputs.webapp == 'true'
uses: ./.github/workflows/runops-guard.yml
webapp:
needs: changes
if: needs.changes.outputs.webapp == 'true'
@@ -161,6 +167,7 @@ jobs:
- changes
- code-quality
- typecheck
- runops-guard
- webapp
- e2e-webapp
- packages
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Install dependencies
+2 -2
View File
@@ -90,7 +90,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
@@ -303,7 +303,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
+38
View File
@@ -0,0 +1,38 @@
name: "🛡️ Run-ops Legacy Guard"
on:
workflow_call:
permissions:
contents: read
jobs:
runops-guard:
runs-on: warp-ubuntu-latest-x64-16x
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.33.2
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🛡️ Run-ops legacy guard
run: pnpm --filter webapp run guard:runops-legacy -- --check
+3 -3
View File
@@ -70,7 +70,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🥟 Setup Bun
@@ -112,7 +112,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 🦕 Setup Deno
@@ -158,7 +158,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
- name: 📥 Download deps
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
+2 -2
View File
@@ -66,7 +66,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -146,7 +146,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -160,7 +160,7 @@ jobs:
- name: ⎔ Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.23.1
node-version: 24.18.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+1 -1
View File
@@ -1 +1 @@
v22.23.1
v24.18.0
+21 -2
View File
@@ -1,7 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
"jsPlugins": [
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
"./oxlint-plugins/runops-residency.mjs"
],
"ignorePatterns": [
"**/dist/**",
"**/build/**",
@@ -34,5 +37,21 @@
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"trigger/no-thrown-unawaited-redirect": "error"
}
},
"overrides": [
{
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "error",
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
"rules": {
"trigger-runops/no-control-plane-run-graph-access": "off",
"trigger-runops/no-control-plane-in-runops-slot": "off"
}
}
]
}
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Transient internal sync failures are now retried quietly instead of surfacing as errors.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Optionally route ClickHouse read traffic to a read replica while writes stay on the primary. Set `CLICKHOUSE_READER_URL` to move all reads, or target the busiest paths with `RUNS_LIST_CLICKHOUSE_URL` (runs list) and `EVENTS_READER_CLICKHOUSE_URL` (traces, spans, logs). All optional; unset keeps current behavior.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Fix batchTrigger requests that set a per-item idempotency key failing with an error instead of creating and deduplicating the runs
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Speed up idempotency checks on `batchTrigger` calls that use idempotency keys. Large batches against a task with a big run history no longer degrade to multi-second lookups.
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Remove the deprecated realtime stream write endpoint used by retired v3 task clients.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Fix pages occasionally loading unstyled or failing to load during a deploy. The dashboard now reloads automatically to recover.
+2 -2
View File
@@ -29,7 +29,7 @@ branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version 22.23.1
- [Node.js](https://nodejs.org/en) version 24.18.0
- [pnpm package manager](https://pnpm.io/installation) version 10.33.2
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
@@ -49,7 +49,7 @@ branch are tagged into a release periodically.
```
cd trigger.dev
```
3. Ensure you are on the correct version of Node.js (22.23.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
3. Ensure you are on the correct version of Node.js (24.18.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`10.33.2`) as specified in the root `package.json` file.
+1 -1
View File
@@ -1 +1 @@
v22.23.1
v24.18.0
+3 -3
View File
@@ -1,13 +1,13 @@
FROM node:22-alpine@sha256:9bef0ef1e268f60627da9ba7d7605e8831d5b56ad07487d24d1aa386336d1944 AS node-22-alpine
FROM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS node-24-alpine
WORKDIR /app
FROM node-22-alpine AS pruner
FROM node-24-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@2.10.0 prune --scope=supervisor --docker
FROM node-22-alpine AS base
FROM node-24-alpine AS base
RUN apk add --no-cache dumb-init
+2
View File
@@ -120,6 +120,7 @@ class ManagedSupervisor {
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS,
checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL,
} satisfies WorkloadManagerOptions;
this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED
@@ -615,6 +616,7 @@ class ManagedSupervisor {
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runtime: message.backgroundWorker.runtime,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_IO_URING_SECCOMP_PROFILE,
withBlockIoUringSeccompProfile,
} from "./kubernetesPodSpec.js";
const basePodSpec = {
restartPolicy: "Never" as const,
automountServiceAccountToken: false,
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
fsGroup: 1000,
},
};
describe("withBlockIoUringSeccompProfile", () => {
it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => {
for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) {
const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime);
expect(podSpec).toMatchObject({
...basePodSpec,
securityContext: {
...basePodSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
});
}
});
it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => {
for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) {
expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec);
}
});
});
@@ -14,6 +14,7 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
import { getRunnerId } from "../util.js";
import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js";
type ResourceQuantities = {
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
@@ -105,6 +106,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
try {
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
const podSpec = this.opts.checkpointsEnabled
? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime)
: basePodSpec;
await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
@@ -119,7 +125,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
...podSpec,
affinity: this.#getAffinity(opts),
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
terminationGracePeriodSeconds: 60 * 60,
@@ -0,0 +1,33 @@
import type { k8s } from "../clients/kubernetes.js";
/**
* Relative path (kubelet seccomp root) of the profile blocking only io_uring
* syscalls. Must match the profile deployed to worker nodes.
*/
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";
/**
* Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking
* io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this,
* so the profile is only applied for node-24+. Tolerates an "experimental-" prefix.
*/
export function withBlockIoUringSeccompProfile(
podSpec: Omit<k8s.V1PodSpec, "containers">,
runtime: string | null | undefined
): Omit<k8s.V1PodSpec, "containers"> {
const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null;
if (!match || Number(match[1]) < 24) {
return podSpec;
}
return {
...podSpec,
securityContext: {
...podSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
};
}
@@ -16,6 +16,8 @@ export interface WorkloadManagerOptions {
snapshotPollIntervalSeconds?: number;
additionalEnvVars?: Record<string, string>;
dockerAutoremove?: boolean;
// Whether CRIU checkpoint/restore is enabled for this deployment
checkpointsEnabled?: boolean;
}
export interface WorkloadManager {
@@ -40,6 +42,8 @@ export interface WorkloadManagerCreateOptions {
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
// Canonical runtime identifier (e.g. "node", "node-22", "node-24")
runtime?: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
+10
View File
@@ -115,6 +115,16 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
## Transactions
- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`.
- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths.
- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value.
## PAT-authenticated API routes
- **A PAT route must resolve its target org/project scoped to the caller's membership** (`members: { some: { userId } }`, or a helper like `findProjectByRef` / `resolveOrganizationForApiUser`). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — so `ability.can(...)` alone does NOT reject a non-member on self-hosted. The RBAC `authorization` gate enforces the *role*; the membership-scoped query is the *tenant* floor. Skipping it opens cross-org access on OSS.
## React Patterns
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { staleAssetRecoveryScript } from "./StaleAssetRecovery";
// Each staleAssetRecoveryScript() call models a fresh page load: it reads the shared
// sessionStorage budget and returns its own `recover`. We drive recover() directly rather
// than dispatching resource-error events, so accumulated window listeners never fire.
describe("staleAssetRecoveryScript", () => {
let reload: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.clear();
reload = vi.fn();
vi.stubGlobal("location", { reload });
vi.stubGlobal("navigator", { onLine: true });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("reloads on a recovery", () => {
staleAssetRecoveryScript().recover();
expect(reload).toHaveBeenCalledTimes(1);
});
it("reloads only once per page even if several assets fail (re-entrancy guard)", () => {
const { recover } = staleAssetRecoveryScript();
recover();
recover();
recover();
expect(reload).toHaveBeenCalledTimes(1);
});
it("stops reloading once the budget is spent across reloads", () => {
staleAssetRecoveryScript().recover(); // reload 1
staleAssetRecoveryScript().recover(); // reload 2
staleAssetRecoveryScript().recover(); // budget spent -> no reload
expect(reload).toHaveBeenCalledTimes(2);
});
it("does not reload when offline", () => {
vi.stubGlobal("navigator", { onLine: false });
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});
it("does not reload when sessionStorage is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
staleAssetRecoveryScript().recover();
expect(reload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
// Recovers from a rolling deploy rotating the content-hashed /build assets out from
// under a page. Each image serves only its own build and hard-404s unknown hashes, so
// a client can request a hash the serving replica doesn't have and get missing styles
// or a failed asset load. On such a /build load failure we do a bounded full document
// reload: the fresh document (and, under sticky routing, all of its assets) lands on a
// single live build, so the asset resolves. Bounded via sessionStorage so it can never
// loop; when the budget is spent it stops rather than reloading forever.
//
// Deliberately minimal — no fetch interception, no build-version polling, no server
// build-id contract, no form snapshot, no blocking overlay.
// The recovery logic runs as an inline <script> injected before <Links /> (see the
// component below), so it must execute before the app bundle and before the stylesheet
// can fail to load. It is authored as a normal, type-checked and lint-checked function
// and serialized with .toString() at render time — NOT hand-written into a string — so
// the logic is real code the compiler and linter can see. Because it is serialized, it
// must stay fully self-contained: no imports, no references to module scope, and plain
// ES that the bundler won't rewrite to reach a hoisted helper. It returns its `recover`
// closure purely so the unit test can drive the logic directly (the inline IIFE that
// runs in the browser ignores the return value).
export function staleAssetRecoveryScript() {
var KEY = "trigger:assetReload";
var MAX_RELOADS = 2;
var WINDOW_MS = 300000;
var recovering = false;
function budgetAllows() {
try {
var raw = sessionStorage.getItem(KEY);
var state = raw ? (JSON.parse(raw) as { n: number; t: number }) : { n: 0, t: 0 };
if (Date.now() - state.t > WINDOW_MS) state = { n: 0, t: 0 };
if (state.n >= MAX_RELOADS) return false;
sessionStorage.setItem(KEY, JSON.stringify({ n: state.n + 1, t: Date.now() }));
return true;
} catch {
// Storage blocked (private mode / quota): can't bound reloads, so don't auto-reload.
return false;
}
}
function recover() {
// One recovery per page: a broken load fails several /build assets at once and each
// fires its own error event before location.reload() commits — without this guard a
// single incident would burn the entire reload budget.
if (recovering) return;
recovering = true;
// Don't reload into the browser's offline error page.
if (navigator.onLine === false) return;
if (budgetAllows()) location.reload();
}
// Non-bubbling resource load failures (stylesheet, modulepreload, entry <script>) at
// document load — the failure class nothing else covers. Capture phase is required.
window.addEventListener(
"error",
function (event) {
var el = event.target as Element | null;
if (!el || typeof el.tagName !== "string") return; // window/global errors have no tagName
var url =
el.tagName === "LINK"
? (el as HTMLLinkElement).href
: el.tagName === "SCRIPT"
? (el as HTMLScriptElement).src
: null;
if (url && url.indexOf("/build/") !== -1) recover();
},
true
);
// Raw dynamic import() failures in app code. (Remix reloads its own route chunks, so
// that path rarely reaches here.) The message URL isn't reliable cross-browser, so
// match the chunk-load error shape; the once-guard + bounded budget make a rare stray
// reload harmless.
window.addEventListener("unhandledrejection", function (event) {
var message = (event.reason && event.reason.message) || "";
if (
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
) {
recover();
}
});
return { recover };
}
export function StaleAssetRecovery({ isProduction }: { isProduction: boolean }) {
if (!isProduction) {
return null;
}
return (
<script dangerouslySetInnerHTML={{ __html: `(${staleAssetRecoveryScript.toString()})()` }} />
);
}
+6
View File
@@ -54,6 +54,12 @@ export default function handleRequest(
) {
const url = new URL(request.url);
// Stale documents reference /build asset hashes that 404 after a deploy —
// always revalidate HTML. Route-set headers win.
if (!responseHeaders.has("Cache-Control")) {
responseHeaders.set("Cache-Control", "no-cache");
}
if (url.pathname.startsWith("/login")) {
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");
+28 -5
View File
@@ -113,6 +113,8 @@ const EnvironmentSchema = z
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
// (org featureFlags) win regardless.
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
// Gates the create-org management API endpoint (default off).
ORG_CREATION_API_ENABLED: z.string().default("0"),
// "1" gives admins/impersonators an everywhere-preview (default off),
// separate from the per-org rollout flag above.
DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"),
@@ -1716,6 +1718,11 @@ const EnvironmentSchema = z
// Clickhouse
CLICKHOUSE_URL: z.string(),
// Optional read replica endpoint. Read-only clients (logs, query, admin, runsList,
// engine, realtime) default to this when their own URL is unset; writes always stay on
// CLICKHOUSE_URL. Events reads opt in separately via EVENTS_READER_CLICKHOUSE_URL (no
// fallback here). Must share storage with the CLICKHOUSE_URL warehouse.
CLICKHOUSE_READER_URL: z.string().optional(),
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1778,13 +1785,13 @@ const EnvironmentSchema = z
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
@@ -1803,12 +1810,14 @@ const EnvironmentSchema = z
ADMIN_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1821,7 +1830,7 @@ const EnvironmentSchema = z
RUN_ENGINE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(5),
@@ -1833,7 +1842,7 @@ const EnvironmentSchema = z
REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce
.number()
@@ -1844,6 +1853,20 @@ const EnvironmentSchema = z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
// Dedicated ClickHouse pool for the runs list (dashboard + API). Lets us point
// the highest-traffic read path at a read replica without moving ingest/replication
// writes off CLICKHOUSE_URL. Falls back to CLICKHOUSE_URL when unset.
RUNS_LIST_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
RUNS_LIST_CLICKHOUSE_LOG_LEVEL: z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
+36 -27
View File
@@ -100,35 +100,44 @@ export async function inviteMembers({
throw new Error("User does not have access to this organization");
}
const invites = [...new Set(emails)].map(
(email) =>
({
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
rbacRoleId: rbacRoleId ?? null,
}) satisfies Prisma.OrgMemberInviteCreateManyInput
);
// Create one invite per unique email and return ONLY the invites actually
// created by this call. A P2002 means the email is already invited to this org
// (unique org+email) — skip it so one duplicate can't fail the batch, and
// don't return it: callers email exactly what they created, and re-sending an
// already-pending invite is the dedicated resend flow's job (its own cooldown).
const created: Prisma.OrgMemberInviteGetPayload<{
include: { organization: true; inviter: true };
}>[] = [];
await prisma.orgMemberInvite.createMany({
data: invites,
});
for (const email of new Set(emails)) {
try {
const invite = await prisma.orgMemberInvite.create({
data: {
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
rbacRoleId: rbacRoleId ?? null,
},
include: {
organization: true,
inviter: true,
},
});
created.push(invite);
} catch (error) {
if (
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
continue;
}
throw error;
}
}
return await prisma.orgMemberInvite.findMany({
where: {
organizationId: org.id,
inviterId: userId,
email: {
in: emails,
},
},
include: {
organization: true,
inviter: true,
},
});
return created;
}
export async function getInviteFromToken({ token }: { token: string }) {
+5 -1
View File
@@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid";
import slug from "slug";
import { $replica, prisma } from "~/db.server";
import { projectCreated } from "~/services/projectCreated.server";
import { ServiceValidationError } from "~/v3/services/common.server";
import { type Organization, createEnvironment } from "./organization.server";
export type { Project } from "@trigger.dev/database";
@@ -50,7 +51,10 @@ export async function createProject(
if (version === "v3") {
if (!organization.isActivated) {
throw new Error(`Organization can't create v3 projects.`);
throw new ServiceValidationError(
"You must select a plan for this organization before creating projects.",
402
);
}
}
@@ -1,8 +1,10 @@
import type { PrismaClient } from "@trigger.dev/database";
import { ServiceValidationError } from "~/v3/services/common.server";
// Leaf module with a type-only Prisma import (caller passes the client) so it
// can be unit-tested without importing `~/db.server`, which eagerly connects
// the global prisma singleton.
// the global prisma singleton. ServiceValidationError is a plain error class
// with no imports, so it stays leaf-safe and lets callers map it to a status.
export async function removeTeamMember(
{
userId,
@@ -20,22 +22,35 @@ export async function removeTeamMember(
});
if (!org) {
throw new Error("User does not have access to this organization");
throw new ServiceValidationError("User does not have access to this organization", 403);
}
// Scope both the lookup and the delete to org.id, in a transaction, so the
// member id is only ever resolved within the actor's organization.
return prismaClient.$transaction(async (tx) => {
const target = await tx.orgMember.findFirst({
where: { id: memberId, organizationId: org.id },
include: { organization: true, user: true },
});
// Serializable so the "keep at least one member" check and the delete are
// atomic: at ReadCommitted two concurrent removals could each see >1 member
// and both delete, orphaning the org. The guard lives here, not per-caller,
// so every surface (dashboard + management API) is TOCTOU-safe. Raw
// $transaction (not the ~/db.server helper) keeps this module leaf/testable.
return prismaClient.$transaction(
async (tx) => {
// Scope both the lookup and the delete to org.id, so the member id is
// only ever resolved within the actor's organization.
const target = await tx.orgMember.findFirst({
where: { id: memberId, organizationId: org.id },
include: { organization: true, user: true },
});
if (!target) {
throw new Error("Member not found in this organization");
}
if (!target) {
throw new ServiceValidationError("Member not found in this organization", 404);
}
await tx.orgMember.delete({ where: { id: target.id } });
return target;
});
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
if (memberCount <= 1) {
throw new ServiceValidationError("Cannot remove the last member of an organization", 400);
}
await tx.orgMember.delete({ where: { id: target.id } });
return target;
},
{ isolationLevel: "Serializable" }
);
}
+13 -5
View File
@@ -8,10 +8,14 @@ export async function createWaitpointTag({
tag,
environmentId,
projectId,
residency,
}: {
tag: string;
environmentId: string;
projectId: string;
// Residency from the env mint kind: a tag has no owning run, so a minted-new env pins it to NEW
// instead of defaulting to the draining legacy DB.
residency?: "NEW" | "LEGACY";
}) {
if (tag.trim().length === 0) return;
@@ -19,11 +23,15 @@ export async function createWaitpointTag({
while (attempts < MAX_RETRIES) {
try {
return await runStore.upsertWaitpointTag({
environmentId,
name: tag,
projectId,
});
return await runStore.upsertWaitpointTag(
{
environmentId,
name: tag,
projectId,
},
undefined,
residency
);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// Handle unique constraint violation (conflict)
@@ -290,7 +290,7 @@ export class ApiRunListPresenter extends BasePresenter {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
"runsList"
);
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
@@ -43,6 +43,28 @@ type BatchRow = {
batchVersion: string;
};
// Composite keyset cursor "<createdAt-epoch-ms>_<id>". Ordering is by createdAt then id: a batch id is
// a cuid (legacy) OR a run-ops id (new), and the two schemes occupy different lexical ranges, so `id`
// alone is not a valid chronological order across the residency split. `id` is the stable tiebreak.
// Old plain-id cursors (no "_") decode to undefined and restart from page 1 (self-healing).
type BatchCursor = { createdAt: Date; id: string };
function encodeBatchCursor(row: BatchCursor): string {
return `${row.createdAt.getTime()}_${row.id}`;
}
function decodeBatchCursor(cursor: string | undefined): BatchCursor | undefined {
if (!cursor) return undefined;
const sep = cursor.indexOf("_");
if (sep === -1) return undefined;
const ms = Number(cursor.slice(0, sep));
const id = cursor.slice(sep + 1);
// Number.isFinite accepts e.g. 1e20, but new Date(1e20) is Invalid Date — reject it so a malformed
// URL cursor self-heals to page 1 instead of reaching Prisma with an invalid date.
const createdAt = new Date(ms);
if (!Number.isFinite(ms) || Number.isNaN(createdAt.getTime()) || id.length === 0)
return undefined;
return { createdAt, id };
}
export class BatchListPresenter extends BasePresenter {
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
// reads from `_replica` exactly as today (passthrough). Field names are local to
@@ -86,17 +108,16 @@ export class BatchListPresenter extends BasePresenter {
return scan(passthrough);
}
const newRows = await scan(this.readRoute.runOpsNew ?? passthrough);
// Always read BOTH stores and merge. The old "skip legacy when new fills the page" shortcut is
// unsound across the residency split: legacy cuid ids ("c…") sort ABOVE new run-ops ids ("0…")
// under id order, so a new-only page can hide pre-flip legacy batches that belong ahead of it.
// Ordering is by createdAt (id tiebreak), which is chronologically correct across both schemes.
const [newRows, legacyRows] = await Promise.all([
scan(this.readRoute.runOpsNew ?? passthrough),
scan(this.readRoute.runOpsLegacyReplica ?? passthrough),
]);
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
if (newRows.length >= pageSize + 1) {
return newRows;
}
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? passthrough);
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch LIMIT.
const byId = new Map<string, BatchRow>();
for (const row of newRows) {
byId.set(row.id, row);
@@ -107,10 +128,16 @@ export class BatchListPresenter extends BasePresenter {
}
}
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or run-ops id).
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
// forward => newest-first (createdAt DESC), backward => oldest-first (ASC); id is the stable
// tiebreak (ASCII codepoint, NEVER localeCompare).
const sign = direction === "forward" ? 1 : -1;
return Array.from(byId.values())
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
.sort((a, b) => {
const at = a.createdAt.getTime();
const bt = b.createdAt.getTime();
if (at !== bt) return at < bt ? sign : -sign;
return a.id < b.id ? sign : a.id > b.id ? -sign : 0;
})
.slice(0, pageSize + 1);
}
@@ -212,11 +239,28 @@ export class BatchListPresenter extends BasePresenter {
}
const createdAtLte: Date | undefined = time.to;
// Composite (createdAt, id) keyset — see encodeBatchCursor. An old plain-id cursor decodes to
// undefined and restarts from page 1.
const keyCursor = decodeBatchCursor(cursor);
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
client.batchTaskRun.findMany({
where: {
runtimeEnvironmentId: environmentId,
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
...(keyCursor
? {
OR:
direction === "forward"
? [
{ createdAt: { lt: keyCursor.createdAt } },
{ createdAt: keyCursor.createdAt, id: { lt: keyCursor.id } },
]
: [
{ createdAt: { gt: keyCursor.createdAt } },
{ createdAt: keyCursor.createdAt, id: { gt: keyCursor.id } },
],
}
: {}),
...(friendlyId ? { friendlyId } : {}),
...(statuses && statuses.length > 0
? { status: { in: statuses }, batchVersion: { not: "v1" } }
@@ -230,7 +274,10 @@ export class BatchListPresenter extends BasePresenter {
}
: {}),
},
orderBy: { id: direction === "forward" ? "desc" : "asc" },
orderBy: [
{ createdAt: direction === "forward" ? "desc" : "asc" },
{ id: direction === "forward" ? "desc" : "asc" },
],
take: pageSize + 1,
select: {
id: true,
@@ -248,23 +295,24 @@ export class BatchListPresenter extends BasePresenter {
const hasMore = batches.length > pageSize;
//get cursors for next and previous pages
//get cursors for next and previous pages (composite (createdAt, id) keyset)
const cur = (row?: BatchRow) => (row ? encodeBatchCursor(row) : undefined);
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? batches.at(0)?.id : undefined;
previous = cursor ? cur(batches.at(0)) : undefined;
if (hasMore) {
next = batches[pageSize - 1]?.id;
next = cur(batches[pageSize - 1]);
}
break;
case "backward":
batches.reverse();
if (hasMore) {
previous = batches[1]?.id;
next = batches[pageSize]?.id;
previous = cur(batches[1]);
next = cur(batches[pageSize]);
} else {
next = batches[pageSize - 1]?.id;
next = cur(batches[pageSize - 1]);
}
break;
}
+8
View File
@@ -7,6 +7,7 @@ import type { ToastMessage } from "~/models/message.server";
import { commitSession, getSession } from "~/models/message.server";
import tailwindStylesheetUrl from "~/tailwind.css";
import { RouteErrorDisplay } from "./components/ErrorDisplay";
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
import { Toast } from "./components/primitives/Toast";
@@ -18,6 +19,11 @@ import { getUser } from "./services/session.server";
import { getTimezonePreference } from "./services/preferences/uiPreferences.server";
import { appEnvTitleTag } from "./utils";
// Derived here (not inside StaleAssetRecovery) so the shared component takes
// the flag as a prop. NODE_ENV is statically replaced in browser bundles, and
// the ErrorBoundary can't rely on loader data.
const isProduction = process.env.NODE_ENV === "production";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
};
@@ -99,6 +105,7 @@ export function ErrorBoundary() {
<head>
<meta charSet="utf-8" />
<StaleAssetRecovery isProduction={isProduction} />
<Meta />
<Links />
</head>
@@ -125,6 +132,7 @@ export default function App() {
<>
<html lang="en" className="h-full" data-theme="dark">
<head>
<StaleAssetRecovery isProduction={isProduction} />
<Meta />
<Links />
</head>
@@ -303,6 +303,7 @@ export default function Page() {
<RuntimeIcon
runtime={deployment.runtime}
runtimeVersion={deployment.runtimeVersion}
withLabel
/>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
@@ -7,7 +7,7 @@ import {
MapPinIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -51,9 +51,11 @@ import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import {
docsPath,
EnvironmentParamSchema,
@@ -90,44 +92,60 @@ const FormSchema = z.object({
regionId: z.string(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
},
async ({ user, ability, request, params }) => {
const { organizationSlug, projectParam, envParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
const redirectPath = regionsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
const redirectPath = regionsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!ability.can("manage", { type: "project" })) {
throw await redirectWithErrorMessage(
redirectPath,
request,
"You don't have permission to change the default region"
);
}
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const formData = await request.formData();
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}
const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
}
const formData = await request.formData();
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}
const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
};
);
export default function Page() {
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();
@@ -193,7 +193,7 @@ async function getRunsListFromTableState({
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
@@ -104,7 +104,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const presenter = new NextRunListPresenter($replica, clickhouse);
const list = presenter.call(project.organizationId, environment.id, {
@@ -2,7 +2,7 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
@@ -19,9 +19,10 @@ import { Label } from "~/components/primitives/Label";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
import { useState } from "react";
@@ -59,98 +60,120 @@ function createSchema(
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json(
{ errors: { body: "organizationSlug and projectParam are required" } },
{ status: 400 }
);
}
const Params = z.object({
organizationSlug: z.string(),
projectParam: z.string(),
});
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
});
const submission = parseWithZod(formData, { schema });
},
async ({ user, ability, request, params }) => {
const userId = user.id;
const { organizationSlug, projectParam } = params;
if (submission.status !== "success") {
return json(submission.reply());
}
const formData = await request.formData();
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parseWithZod(formData, { schema });
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
if (submission.status !== "success") {
return json(submission.reply());
}
const { projectId } = membershipResultOrFail.value;
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
switch (submission.value.action) {
case "rename": {
const resultOrFail = await projectSettingsService.renameProject(
projectId,
submission.value.projectName
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
const { projectId } = membershipResultOrFail.value;
logger.error("Failed to rename project", {
error: resultOrFail.error,
});
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
switch (submission.value.action) {
case "rename": {
if (!ability.can("manage", { type: "project" })) {
throw await redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"You don't have permission to rename this project"
);
}
const resultOrFail = await projectSettingsService.renameProject(
projectId,
submission.value.projectName
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to rename project", {
error: resultOrFail.error,
});
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
}
}
}
return redirectWithSuccessMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
if (!ability.can("manage", { type: "project" })) {
throw await redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"You don't have permission to delete this project"
);
}
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
return redirectWithSuccessMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to delete project", {
error: resultOrFail.error,
});
return redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project ${projectParam} could not be deleted`
);
logger.error("Failed to delete project", {
error: resultOrFail.error,
});
return redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
}
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
"Project deleted"
);
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
"Project deleted"
);
}
}
}
};
);
export default function GeneralSettingsPage() {
const project = useProject();
@@ -9,7 +9,7 @@ import {
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useRef, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -45,10 +45,12 @@ import { SpinnerWhite } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { logger } from "~/services/logger.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon";
import { OrganizationParamsSchema, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
@@ -143,98 +145,147 @@ export function createSchema(
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { organizationSlug } = params;
if (!organizationSlug) {
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
}
const Params = z.object({
organizationSlug: z.string(),
});
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === organizationSlug, organizationSlug };
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
});
const submission = parseWithZod(formData, { schema });
},
async ({ ability, request, params }) => {
// clearCurrentProject (delete branch) needs the full UserFromSession
// (dashboardPreferences), which the builder's SessionUser doesn't carry.
const user = await requireUser(request);
const { organizationSlug } = params;
if (submission.status !== "success") {
return json(submission.reply());
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === organizationSlug, organizationSlug };
},
});
const submission = parseWithZod(formData, { schema });
try {
switch (submission.value.action) {
case "rename": {
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
if (submission.status !== "success") {
return json(submission.reply());
}
try {
switch (submission.value.action) {
case "rename": {
if (!ability.can("manage", { type: "organization" })) {
throw await redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
"You don't have permission to rename this organization"
);
}
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
},
data: {
title: submission.value.organizationName,
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Organization renamed to ${submission.value.organizationName}`
);
}
case "delete": {
const deleteOrganizationService = new DeleteOrganizationService();
try {
await deleteOrganizationService.call({ organizationSlug, userId: user.id, request });
//we need to clear the project from the session
await clearCurrentProject({
user,
data: {
title: submission.value.organizationName,
},
});
return redirect(rootPath());
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
logger.error("Organization could not be deleted", {
error: errorMessage,
});
return redirectWithErrorMessage(
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
errorMessage
`Organization renamed to ${submission.value.organizationName}`
);
}
}
case "avatar": {
const orgWhere = {
slug: organizationSlug,
members: { some: { userId: user.id } },
};
case "delete": {
if (!ability.can("manage", { type: "organization" })) {
throw await redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
"You don't have permission to delete this organization"
);
}
const deleteOrganizationService = new DeleteOrganizationService();
try {
await deleteOrganizationService.call({ organizationSlug, userId: user.id, request });
if (submission.value.type === "image") {
const url = submission.value.url ?? "";
const domain = url ? extractDomain(url) : null;
//we need to clear the project from the session
await clearCurrentProject({
user,
});
return redirect(rootPath());
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
logger.error("Organization could not be deleted", {
error: errorMessage,
});
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
errorMessage
);
}
}
case "avatar": {
const orgWhere = {
slug: organizationSlug,
members: { some: { userId: user.id } },
};
const existing = await prisma.organization.findFirst({
where: orgWhere,
select: { avatar: true, onboardingData: true },
});
if (submission.value.type === "image") {
const url = submission.value.url ?? "";
const domain = url ? extractDomain(url) : null;
const existingData = toRecord(existing?.onboardingData);
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
const lastIconHex = extractLastIconHex(existingAvatar);
const existing = await prisma.organization.findFirst({
where: orgWhere,
select: { avatar: true, onboardingData: true },
});
const existingData = toRecord(existing?.onboardingData);
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
const lastIconHex = extractLastIconHex(existingAvatar);
await prisma.organization.update({
where: orgWhere,
data: {
avatar: {
type: "image",
url: domain ? buildFaviconUrl(domain) : "",
...(lastIconHex ? { lastIconHex } : {}),
},
onboardingData: { ...existingData, companyUrl: url },
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated logo`
);
}
const avatar = AvatarData.safeParse(submission.value);
if (!avatar.success) {
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
avatar.error.message
);
}
await prisma.organization.update({
where: orgWhere,
data: {
avatar: {
type: "image",
url: domain ? buildFaviconUrl(domain) : "",
...(lastIconHex ? { lastIconHex } : {}),
},
onboardingData: { ...existingData, companyUrl: url },
avatar: avatar.data,
},
});
@@ -244,36 +295,16 @@ export const action: ActionFunction = async ({ request, params }) => {
`Updated logo`
);
}
const avatar = AvatarData.safeParse(submission.value);
if (!avatar.success) {
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
avatar.error.message
);
}
await prisma.organization.update({
where: orgWhere,
data: {
avatar: avatar.data,
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated logo`
);
}
} catch (error: unknown) {
// Permission checks throw a redirect Response (toast) — let it through
// rather than flattening it into a generic 400.
if (error instanceof Response) throw error;
const message = error instanceof Error ? error.message : "An unexpected error occurred";
return json({ errors: { body: message } }, { status: 400 });
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "An unexpected error occurred";
return json({ errors: { body: message } }, { status: 400 });
}
};
);
export default function Page() {
const { organization } = useTypedLoaderData<typeof loader>();
@@ -0,0 +1,54 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { revokeInvite } from "~/models/member.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
orgParam: z.string(),
inviteId: z.string(),
});
export const action = createActionPATApiRoute(
{
method: "DELETE",
params: ParamsSchema,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:members gate below.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: () => ({ type: "members" }) },
},
async ({ params, authentication }) => {
// Membership floor: a non-member gets a 404.
const organization = await resolveOrganizationForApiUser({
orgParam: params.orgParam,
userId: authentication.userId,
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
try {
const revoked = await revokeInvite({
userId: authentication.userId,
orgSlug: organization.slug,
inviteId: params.inviteId,
});
return json({ id: params.inviteId, email: revoked.email });
} catch (error) {
if (error instanceof Error && error.message === "Invite not found") {
return json({ error: error.message }, { status: 404 });
}
throw error;
}
}
);
@@ -0,0 +1,99 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { inviteMembers } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { ssoController } from "~/services/sso.server";
import { acceptInvitePath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
orgParam: z.string(),
});
const InviteRequestBody = z.object({
emails: z
.string()
.email()
.array()
.nonempty("At least one email is required")
.max(50, "At most 50 emails per request"),
});
export const action = createActionPATApiRoute(
{
method: "POST",
params: ParamsSchema,
body: InviteRequestBody,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:members gate below.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: () => ({ type: "members" }) },
},
async ({ params, body, authentication }) => {
// Membership floor: a non-member gets a 404.
const organization = await resolveOrganizationForApiUser({
orgParam: params.orgParam,
userId: authentication.userId,
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
// Directory-managed membership: inviting is disabled (mirrors the dashboard
// invite action). Fail-open on a plugin error.
const policy = await ssoController.getMembershipPolicy(organization.id);
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
}
// Returns only the invites created by this call; already-invited emails are
// skipped (re-sending is the dashboard's dedicated resend flow, not this).
const created = await inviteMembers({
slug: organization.slug,
emails: body.emails,
userId: authentication.userId,
});
// Email only the newly-created invites. A failed send must not fail the
// request (the row exists); locally scheduleEmail's transport just logs.
for (const invite of created) {
try {
await scheduleEmail({
email: "invite",
to: invite.email,
orgName: invite.organization.title,
inviterName: invite.inviter.name ?? undefined,
inviterEmail: invite.inviter.email,
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
});
} catch (error) {
logger.error("Failed to send invite email", { error });
}
}
// Report per-email outcome so callers aren't misled by an empty list on
// re-invite. 201 when something was created, 200 when everything already
// existed.
const createdEmails = new Set(created.map((invite) => invite.email));
const alreadyInvited = [...new Set(body.emails)].filter((email) => !createdEmails.has(email));
return json(
{
invited: created.map((invite) => ({ id: invite.id, email: invite.email })),
alreadyInvited,
},
{ status: created.length > 0 ? 201 : 200 }
);
}
);
@@ -0,0 +1,78 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { removeTeamMember } from "~/models/removeTeamMember.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ssoController } from "~/services/sso.server";
const ParamsSchema = z.object({
orgParam: z.string(),
memberId: z.string(),
});
export const action = createActionPATApiRoute(
{
method: "DELETE",
params: ParamsSchema,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:members gate below.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: () => ({ type: "members" }) },
},
async ({ params, authentication }) => {
// Membership floor: a non-member gets a 404.
const organization = await resolveOrganizationForApiUser({
orgParam: params.orgParam,
userId: authentication.userId,
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
// Directory-managed membership: manual removal is disabled (mirrors the
// dashboard Team page). Fail-open on a plugin error.
const policy = await ssoController.getMembershipPolicy(organization.id);
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
}
// Org-scoped, TOCTOU-safe delete shared with the dashboard Team page. The
// model throws ServiceValidationError (member-not-found 404, last-member
// guard 400), which the builder maps to the response status.
const removed = await removeTeamMember(
{
userId: authentication.userId,
slug: organization.slug,
memberId: params.memberId,
},
prisma
);
// Sticky removal: record a tombstone so passive SSO-JIT won't re-add them
// (best-effort; no-op without the SSO plugin).
await ssoController
.recordMembershipRemoval({
organizationId: organization.id,
userId: removed.userId,
reason: "manual_removal",
})
.unwrapOr(undefined);
return json({
id: removed.id,
user: {
id: removed.user.id,
name: removed.user.name,
email: removed.user.email,
},
});
}
);
@@ -0,0 +1,69 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { getTeamMembersAndInvites } from "~/models/member.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
orgParam: z.string(),
});
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the read:members gate below.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
authorization: { action: "read", resource: () => ({ type: "members" }) },
},
async ({ params, authentication }) => {
// Membership floor: a non-member gets a 404.
const organization = await resolveOrganizationForApiUser({
orgParam: params.orgParam,
userId: authentication.userId,
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
const result = await getTeamMembersAndInvites({
userId: authentication.userId,
organizationId: organization.id,
});
if (!result) {
return json({ error: "Organization not found" }, { status: 404 });
}
return json({
members: result.members.map((member) => ({
id: member.id,
role: member.role,
user: {
id: member.user.id,
name: member.user.name,
email: member.user.email,
avatarUrl: member.user.avatarUrl,
},
})),
invites: result.invites.map((invite) => ({
id: invite.id,
email: invite.email,
updatedAt: invite.updatedAt,
inviter: {
id: invite.inviter.id,
name: invite.inviter.name,
email: invite.inviter.email,
},
})),
});
}
);
@@ -1,4 +1,3 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { GetProjectResponseBody, GetProjectsResponseBody } from "@trigger.dev/core/v3";
import { CreateProjectRequestBody, tryCatch } from "@trigger.dev/core/v3";
@@ -6,33 +5,30 @@ import { z } from "zod";
import { prisma } from "~/db.server";
import { createProject } from "~/models/project.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import {
createActionPATApiRoute,
createLoaderPATApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/common.server";
import { isCuid } from "cuid";
const ParamsSchema = z.object({
orgParam: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("get projects", { url: request.url });
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const { orgParam } = ParamsSchema.parse(params);
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
},
async ({ params, authentication }) => {
const projects = await prisma.project.findMany({
where: {
organization: {
...orgParamWhereClause(orgParam),
...orgParamWhereClause(params.orgParam),
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
userId: authentication.userId,
},
},
},
@@ -41,6 +37,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
},
include: {
organization: true,
defaultWorkerGroup: { select: { name: true } },
},
});
@@ -54,6 +51,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion: project.defaultWorkerGroup?.name ?? null,
organization: {
id: project.organization.id,
title: project.organization.title,
@@ -63,30 +61,34 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
}));
return json(result);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to list org projects", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function action({ request, params }: ActionFunctionArgs) {
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const { orgParam } = ParamsSchema.parse(params);
);
export const action = createActionPATApiRoute(
{
method: "POST",
params: ParamsSchema,
body: CreateProjectRequestBody,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { ...orgParamWhereClause(orgParam), deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
// No authorization gate: creating a project is a member-level action
// (mirrors the dashboard), not an owner-only one like rename/delete.
},
async ({ params, body, authentication }) => {
const organization = await prisma.organization.findFirst({
where: {
...orgParamWhereClause(orgParam),
...orgParamWhereClause(params.orgParam),
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
userId: authentication.userId,
},
},
},
@@ -96,33 +98,41 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Organization not found" }, { status: 404 });
}
const body = await request.json();
const parsedBody = CreateProjectRequestBody.safeParse(body);
if (!parsedBody.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const [error, project] = await tryCatch(
createProject({
organizationSlug: organization.slug,
name: parsedBody.data.name,
userId: authenticationResult.userId,
name: body.name,
userId: authentication.userId,
version: "v3",
})
);
if (error) {
logger.error("Failed to create project", { error });
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 400 });
}
return json({ error: "Failed to create project" }, { status: 400 });
}
// Derive from the stored id rather than assuming new projects are unset,
// so this stays correct if project creation ever inherits a default region.
const defaultRegion = project.defaultWorkerGroupId
? ((
await prisma.workerInstanceGroup.findFirst({
where: { id: project.defaultWorkerGroupId },
select: { name: true },
})
)?.name ?? null)
: null;
const result: GetProjectResponseBody = {
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion,
organization: {
id: project.organization.id,
title: project.organization.title,
@@ -132,12 +142,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
};
return json(result);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to create org project", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
);
function orgParamWhereClause(orgParam: string) {
// If the orgParam is an ID, or if it's a slug
@@ -0,0 +1,88 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
orgParam: z.string(),
});
const RenameOrgRequestBody = z.object({
title: z.string().trim().min(3).max(50),
});
// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and
// the handler branches. No builder body schema — DELETE has none, so the PATCH
// branch parses its own body.
export const action = createActionPATApiRoute(
{
method: ["PATCH", "DELETE"],
params: ParamsSchema,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:organization gate below.
context: async ({ orgParam }) => {
const org = await prisma.organization.findFirst({
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
select: { id: true },
});
return org ? { organizationId: org.id } : {};
},
authorization: { action: "manage", resource: () => ({ type: "organization" }) },
},
async ({ request, params, authentication }) => {
// Membership floor: a non-member gets a 404 (the authorization block
// enforces the role; this enforces membership).
const organization = await resolveOrganizationForApiUser({
orgParam: params.orgParam,
userId: authentication.userId,
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
const method = request.method.toUpperCase();
if (method === "DELETE") {
try {
await new DeleteOrganizationService().call({
organizationSlug: organization.slug,
userId: authentication.userId,
request,
});
} catch (error) {
// The service throws Errors with user-facing messages (active
// subscription, already deleted, etc.).
return json(
{ error: error instanceof Error ? error.message : "Failed to delete organization" },
{ status: 400 }
);
}
return json({ id: organization.id });
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return json({ error: "Invalid request body" }, { status: 400 });
}
const body = RenameOrgRequestBody.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const updated = await prisma.organization.update({
where: { id: organization.id },
data: { title: body.data.title },
select: { id: true, title: true, slug: true },
});
return json(updated);
}
);
+77 -33
View File
@@ -1,44 +1,88 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { GetOrgsResponseBody } from "@trigger.dev/core/v3";
import { CreateOrgRequestBody } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { env } from "~/env.server";
import { createOrganization } from "~/models/organization.server";
import {
createActionPATApiRoute,
createLoaderPATApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { extractDomain, faviconUrl } from "~/utils/favicon";
export async function loader({ request }: LoaderFunctionArgs) {
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const orgs = await prisma.organization.findMany({
where: {
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
},
// Identity-only: lists the caller's own orgs, so no authorization gate.
export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => {
const orgs = await prisma.organization.findMany({
where: {
deletedAt: null,
members: {
some: {
userId: authentication.userId,
},
},
});
},
});
if (!orgs) {
return json({ error: "Orgs not found" }, { status: 404 });
if (!orgs) {
return json({ error: "Orgs not found" }, { status: 404 });
}
const result: GetOrgsResponseBody = orgs.map((org) => ({
id: org.id,
title: org.title,
slug: org.slug,
createdAt: org.createdAt,
}));
return json(result);
});
// No org exists yet, so no authorization gate; any authenticated user can
// create an org and becomes its ADMIN.
export const action = createActionPATApiRoute(
{
method: "POST",
body: CreateOrgRequestBody,
},
async ({ body, authentication }) => {
if (env.ORG_CREATION_API_ENABLED !== "1") {
return json({ error: "Not found" }, { status: 404 });
}
const result: GetOrgsResponseBody = orgs.map((org) => ({
id: org.id,
title: org.title,
slug: org.slug,
createdAt: org.createdAt,
}));
// Mirror the dashboard: stash companyUrl/companySize as onboarding data and
// derive the org avatar from the company domain's favicon.
const onboardingData: Record<string, string> = {};
if (body.companyUrl) {
onboardingData.companyUrl = body.companyUrl;
}
if (body.companySize) {
onboardingData.companySize = body.companySize;
}
return json(result);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to list orgs", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
let avatar: { type: "image"; url: string } | undefined;
if (body.companyUrl) {
const domain = extractDomain(body.companyUrl);
if (domain) {
avatar = { type: "image", url: faviconUrl(domain) };
}
}
const organization = await createOrganization({
title: body.title,
companySize: body.companySize ?? null,
userId: authentication.userId,
onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined,
avatar,
});
return json(
{
id: organization.id,
title: organization.title,
slug: organization.slug,
createdAt: organization.createdAt,
},
{ status: 201 }
);
}
}
);
@@ -0,0 +1,74 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
branchNameFromRequest,
} from "~/services/apiAuth.server";
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
import { logger } from "~/services/logger.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
const ParamsSchema = z.object({
projectRef: z.string(),
env: z.enum(["dev", "staging", "prod", "preview"]),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef, env } = parsedParams.data;
try {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: false,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
branchNameFromRequest(request)
);
// Same env-tier gate as the regenerate-api-key route: managing an
// environment's operational state is an env-admin action.
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "write",
});
if (denied) return denied;
const result = await new PauseEnvironmentService().call(environment, "paused");
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
return json({ paused: true, state: result.state });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to pause environment", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -0,0 +1,73 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { regenerateApiKey } from "~/models/api-key.server";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
branchNameFromRequest,
} from "~/services/apiAuth.server";
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
import { logger } from "~/services/logger.server";
const ParamsSchema = z.object({
projectRef: z.string(),
env: z.enum(["dev", "staging", "prod", "preview"]),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef, env } = parsedParams.data;
try {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: false,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
branchNameFromRequest(request)
);
// Rotating the key requires env-tier write:apiKeys — same gate the
// dashboard resource route enforces.
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "write",
});
if (denied) return denied;
const updatedEnvironment = await regenerateApiKey({
userId: authenticationResult.result.userId,
environmentId: environment.id,
});
return json({ apiKey: updatedEnvironment.apiKey });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to regenerate API key", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -0,0 +1,74 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
branchNameFromRequest,
} from "~/services/apiAuth.server";
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
import { logger } from "~/services/logger.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
const ParamsSchema = z.object({
projectRef: z.string(),
env: z.enum(["dev", "staging", "prod", "preview"]),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef, env } = parsedParams.data;
try {
const authenticationResult = await authenticateRequest(request, {
personalAccessToken: true,
organizationAccessToken: false,
apiKey: false,
});
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const environment = await authenticatedEnvironmentForAuthentication(
authenticationResult,
projectRef,
env,
branchNameFromRequest(request)
);
// Same env-tier gate as the regenerate-api-key route: managing an
// environment's operational state is an env-admin action.
const denied = await authorizePatEnvironmentAccess({
request,
authType: authenticationResult.type,
organizationId: environment.organizationId,
projectId: environment.project.id,
envType: environment.type,
resource: "apiKeys",
action: "write",
});
if (denied) return denied;
const result = await new PauseEnvironmentService().call(environment, "resumed");
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
return json({ paused: false, state: result.state });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to resume environment", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -0,0 +1,89 @@
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
const SetDefaultRegionRequestBody = z.object({
// The worker group name (region name), e.g. "aws-us-east-1".
region: z.string().min(1),
});
// Clearing the default is unsupported: SetDefaultRegionService has no path to
// unset defaultWorkerGroupId, so only PUT is implemented (other methods 405).
export const action = createActionPATApiRoute(
{
method: "PUT",
params: ParamsSchema,
body: SetDefaultRegionRequestBody,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:project gate below.
context: async ({ projectRef }) => {
const project = await prisma.project.findFirst({
where: { externalRef: projectRef, deletedAt: null },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
authorization: { action: "manage", resource: () => ({ type: "project" }) },
},
async ({ params, body, authentication }) => {
// Membership floor: resolve scoped to the caller so a non-member gets a 404
// (the authorization block enforces the role; this enforces membership).
const project = await prisma.project.findFirst({
where: {
externalRef: params.projectRef,
organization: {
deletedAt: null,
members: { some: { userId: authentication.userId } },
},
deletedAt: null,
},
select: { id: true, slug: true },
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
// Resolve the region name to a worker group id the same way the dashboard
// does — through the presenter, which filters to regions this project can
// actually use (allowed queues / hidden / compute access). PAT users are
// never admins here.
const presenter = new RegionsPresenter();
const [presenterError, result] = await tryCatch(
presenter.call({ userId: authentication.userId, projectSlug: project.slug })
);
if (presenterError) {
return json({ error: presenterError.message }, { status: 400 });
}
const region = result.regions.find((r) => r.name === body.region);
if (!region) {
return json(
{
error: `Region '${body.region}' not found`,
availableRegions: result.regions.map((r) => r.name),
},
{ status: 400 }
);
}
// SetDefaultRegionService throws ServiceValidationError; the builder maps it
// to its status (default 400).
const updated = await new SetDefaultRegionService().call({
projectId: project.id,
regionId: region.id,
});
return json({ id: updated.id, name: updated.name });
}
);
@@ -62,6 +62,7 @@ export async function action({ params, request }: ActionFunctionArgs) {
const result = await repository.create(environment.project.id, {
override: true,
environmentIds: [environment.id],
isSecret: body.data.isSecret,
variables: [
{
key: body.data.name,
@@ -1,71 +1,142 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { GetProjectResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { DeleteProjectService } from "~/services/deleteProject.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import {
createActionPATApiRoute,
createLoaderPATApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("get project", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef } = parsedParams.data;
const project = await prisma.project.findFirst({
where: {
externalRef: projectRef,
organization: {
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
},
async ({ params, authentication }) => {
const project = await prisma.project.findFirst({
where: {
externalRef: params.projectRef,
organization: {
deletedAt: null,
members: {
some: {
userId: authentication.userId,
},
},
},
deletedAt: null,
},
deletedAt: null,
},
include: {
organization: true,
},
});
include: {
organization: true,
defaultWorkerGroup: { select: { name: true } },
},
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
if (project.version !== "V3") {
return json({ error: "Project found but was not a v3 project" }, { status: 404 });
}
const result: GetProjectResponseBody = {
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion: project.defaultWorkerGroup?.name ?? null,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
},
};
return json(result);
}
);
if (project.version !== "V3") {
return json({ error: "Project found but was not a v3 project" }, { status: 404 });
}
const RenameProjectRequestBody = z.object({
name: z.string().trim().min(1).max(255),
});
const result: GetProjectResponseBody = {
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and
// the handler branches. No builder body schema — DELETE has none, so the PATCH
// branch parses its own body.
export const action = createActionPATApiRoute(
{
method: ["PATCH", "DELETE"],
params: ParamsSchema,
// Resolve the org (id only, no membership) so the plugin can compute the
// caller's role floor for the manage:project gate below.
context: async ({ projectRef }) => {
const project = await prisma.project.findFirst({
where: { externalRef: projectRef, deletedAt: null },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
};
authorization: { action: "manage", resource: () => ({ type: "project" }) },
},
async ({ request, params, authentication }) => {
// Resolve id from ref scoped to membership; the services enforce membership
// again, but this maps a 404 (not member / unknown ref) cleanly.
const project = await prisma.project.findFirst({
where: {
externalRef: params.projectRef,
organization: {
deletedAt: null,
members: { some: { userId: authentication.userId } },
},
deletedAt: null,
},
select: { id: true, organizationId: true },
});
return json(result);
}
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const method = request.method.toUpperCase();
if (method === "DELETE") {
await new DeleteProjectService().call({
projectId: project.id,
userId: authentication.userId,
});
return json({ id: project.id });
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return json({ error: "Invalid request body" }, { status: 400 });
}
const body = RenameProjectRequestBody.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const result = await new ProjectSettingsService().renameProject(project.id, body.data.name);
if (result.isErr()) {
logger.error("Failed to rename project", { error: result.error });
return json({ error: "Failed to rename project" }, { status: 400 });
}
return json({ id: result.value.id, name: result.value.name });
}
);
+38 -51
View File
@@ -1,60 +1,47 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import type { GetProjectsResponseBody } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
export async function loader({ request }: LoaderFunctionArgs) {
logger.info("get projects", { url: request.url });
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const projects = await prisma.project.findMany({
where: {
organization: {
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
},
// Identity-only: lists projects across the caller's orgs, so no authorization gate.
export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => {
const projects = await prisma.project.findMany({
where: {
organization: {
deletedAt: null,
members: {
some: {
userId: authentication.userId,
},
},
version: "V3",
deletedAt: null,
},
include: {
organization: true,
},
});
version: "V3",
deletedAt: null,
},
include: {
organization: true,
defaultWorkerGroup: { select: { name: true } },
},
});
if (!projects) {
return json({ error: "Projects not found" }, { status: 404 });
}
const result: GetProjectsResponseBody = projects.map((project) => ({
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
},
}));
return json(result);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to list projects", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
if (!projects) {
return json({ error: "Projects not found" }, { status: 404 });
}
}
const result: GetProjectsResponseBody = projects.map((project) => ({
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
defaultRegion: project.defaultWorkerGroup?.name ?? null,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
},
}));
return json(result);
});
@@ -16,6 +16,7 @@ import {
type PrismaClientOrTransaction,
} from "~/db.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
import { logger } from "~/services/logger.server";
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
import {
@@ -58,6 +59,16 @@ const { action } = createActionApiRoute(
const timeout = await parseDelay(body.timeout);
// A token (and its tags) has no owning run, so it can't co-locate. Resolve the env mint kind so a
// minted-new env creates them on the run-ops DB (NEW) instead of defaulting to the draining LEGACY
// DB by their cuid id-shape.
const mintKind = await resolveRunIdMintKind({
organizationId: authentication.environment.organizationId,
id: authentication.environment.id,
orgFeatureFlags: authentication.environment.organization.featureFlags,
});
const residency = mintKind === "runOpsId" ? "NEW" : "LEGACY";
//upsert tags
let tags: { id: string; name: string }[] = [];
const bodyTags = typeof body.tags === "string" ? [body.tags] : body.tags;
@@ -74,6 +85,7 @@ const { action } = createActionApiRoute(
tag,
environmentId: authentication.environment.id,
projectId: authentication.environment.projectId,
residency,
});
if (tagRecord) {
tags.push(tagRecord);
@@ -88,6 +100,7 @@ const { action } = createActionApiRoute(
idempotencyKeyExpiresAt,
timeout,
tags: bodyTags,
standaloneResidency: residency,
});
const $responseHeaders = await responseHeaders(authentication.environment);
@@ -1,86 +1,15 @@
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
// Plain action for backwards compatibility with older clients that don't send auth headers
export async function action({ request, params }: ActionFunctionArgs) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return new Response("Invalid parameters", { status: 400 });
}
const { runId, streamId } = parsedParams.data;
// Look up the run without environment scoping for backwards compatibility
const run = await runStore.findRun(
{
friendlyId: runId,
},
{
select: {
id: true,
friendlyId: true,
streamBasinName: true,
runtimeEnvironmentId: true,
},
},
$replica
);
if (!run) {
return new Response("Run not found", { status: 404 });
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId);
if (!environment) {
return new Response("Run not found", { status: 404 });
}
// Extract client ID from header, default to "default" if not provided
const clientId = request.headers.get("X-Client-Id") || "default";
const streamVersion = request.headers.get("X-Stream-Version") || "v1";
if (!request.body) {
return new Response("No body provided", { status: 400 });
}
const resumeFromChunk = request.headers.get("X-Resume-From-Chunk");
let resumeFromChunkNumber: number | undefined = undefined;
if (resumeFromChunk) {
const parsed = parseInt(resumeFromChunk, 10);
if (isNaN(parsed) || parsed < 0) {
return new Response(`Invalid X-Resume-From-Chunk header value: ${resumeFromChunk}`, {
status: 400,
});
}
resumeFromChunkNumber = parsed;
}
const realtimeStream = getRealtimeStreamInstance(environment, streamVersion, {
run,
});
return realtimeStream.ingestData(
request.body,
run.friendlyId,
streamId,
clientId,
resumeFromChunkNumber
);
}
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
@@ -67,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
+12 -7
View File
@@ -47,13 +47,18 @@ class AttioClient {
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}`);
// 5xx/429 are transient (the worker retries); warn + tag so they don't page Sentry. Real 4xx stay error.
const transient = response.status >= 500 || response.status === 429;
const fields = { object, matchingAttribute, status: response.status, body };
if (transient) {
logger.warn("Attio assert failed", fields);
} else {
logger.error("Attio assert failed", fields);
}
const message = `Attio assert ${object} failed with status ${response.status}`;
throw transient
? Object.assign(new Error(message), { logLevel: "warn" as const })
: new Error(message);
}
const recordId = ((await response.json()) as any).data?.id?.record_id;
@@ -251,6 +251,36 @@ function initializeRealtimeClickhouseClient(): ClickHouse {
});
}
/** Runs list reads — dashboard + API (`RUNS_LIST_CLICKHOUSE_URL`);
* falls back to the default client if unset. */
const defaultRunsListClickhouseClient = singleton(
"runsListClickhouseClient",
initializeRunsListClickhouseClient
);
function initializeRunsListClickhouseClient(): ClickHouse {
if (!env.RUNS_LIST_CLICKHOUSE_URL) {
return defaultClickhouseClient;
}
const url = new URL(env.RUNS_LIST_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "runs-list-clickhouse",
keepAlive: {
enabled: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUNS_LIST_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** Task events (`EVENTS_CLICKHOUSE_URL`); not exported — accessed via factory. */
const defaultEventsClickhouseClient = singleton(
"eventsClickhouseClient",
@@ -262,12 +292,10 @@ function initializeEventsClickhouseClient(): ClickHouse {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
const writerUrl = new URL(env.EVENTS_CLICKHOUSE_URL);
writerUrl.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "task-events",
const commonConfig = {
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
@@ -277,6 +305,28 @@ function initializeEventsClickhouseClient(): ClickHouse {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
};
// Mixed read+write client: split reads to its own EVENTS_READER_CLICKHOUSE_URL (not the global reader) so inserts can never hit the replica.
if (env.EVENTS_READER_CLICKHOUSE_URL) {
const readerUrl = new URL(env.EVENTS_READER_CLICKHOUSE_URL);
readerUrl.searchParams.delete("secure");
if (readerUrl.toString() !== writerUrl.toString()) {
return new ClickHouse({
...commonConfig,
writerName: "task-events-writer",
writerUrl: writerUrl.toString(),
readerName: "task-events-reader",
readerUrl: readerUrl.toString(),
});
}
}
return new ClickHouse({
...commonConfig,
name: "task-events",
url: writerUrl.toString(),
});
}
@@ -298,7 +348,8 @@ export type ClientType =
| "query"
| "admin"
| "engine"
| "realtime";
| "realtime"
| "runsList";
function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHouse {
const parsed = new URL(url);
@@ -388,6 +439,7 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou
case "standard":
case "query":
case "admin":
case "runsList":
return new ClickHouse({
url: parsed.toString(),
name,
@@ -455,6 +507,8 @@ export class ClickhouseFactory {
return defaultRunEngineClickhouseClient;
case "realtime":
return defaultRealtimeClickhouseClient;
case "runsList":
return defaultRunsListClickhouseClient;
}
}
@@ -0,0 +1,24 @@
import { prisma } from "~/db.server";
/**
* Resolve an org from a PAT-authenticated request's `$orgParam` (id or slug),
* scoped to the caller's membership. This membership floor matters: the OSS
* RBAC fallback grants a permissive ability to any PAT, so it can't be relied
* on to reject non-members — resolving through the membership relation does.
*/
export async function resolveOrganizationForApiUser({
orgParam,
userId,
}: {
orgParam: string;
userId: string;
}): Promise<{ id: string; slug: string } | null> {
return prisma.organization.findFirst({
where: {
OR: [{ id: orgParam }, { slug: orgParam }],
deletedAt: null,
members: { some: { userId } },
},
select: { id: true, slug: true },
});
}
@@ -637,6 +637,280 @@ export function createLoaderPATApiRoute<
};
}
// The mutation counterpart to `createLoaderPATApiRoute`. Same PAT/user-actor
// auth + `context` role-floor + `authorization` gating + `tenantContext`
// user attribution, plus a method guard, body parsing, and — unlike the
// loader — `ServiceValidationError` is mapped to its `.status` so services
// can raise typed 4xx errors instead of the route string-matching messages.
// Deliberately self-contained (not sharing internals with the loader) so
// existing PAT loader routes are untouched.
type PATActionMethod = "POST" | "PUT" | "DELETE" | "PATCH";
type PATActionRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
> = PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema> & {
// A single verb, or a list for multi-method routes (e.g. ["PATCH", "DELETE"]).
method?: PATActionMethod | PATActionMethod[];
body?: TBodySchema;
};
type PATActionHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
}) => Promise<Response>;
export function createActionPATApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
>(
options: PATActionRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>,
handler: PATActionHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema, TBodySchema>
) {
return async function action({ request, params }: ActionFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
body: bodySchema,
corsStrategy = "none",
context: contextFn,
authorization,
method,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const allowedMethods = method ? (Array.isArray(method) ? method : [method]) : undefined;
if (allowedMethods && !(allowedMethods as string[]).includes(request.method.toUpperCase())) {
return await wrapResponse(
request,
json(
{ error: "Method not allowed" },
{ status: 405, headers: { Allow: allowedMethods.join(", ") } }
),
corsStrategy !== "none"
);
}
try {
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
let parsedBody: any = undefined;
if (bodySchema) {
const rawBody = await request.text();
if (rawBody.length === 0) {
return await wrapResponse(
request,
json({ error: "Request body is empty" }, { status: 400 }),
corsStrategy !== "none"
);
}
const rawParsedJson = safeJsonParse(rawBody);
if (!rawParsedJson) {
return await wrapResponse(
request,
json({ error: "Invalid JSON" }, { status: 400 }),
corsStrategy !== "none"
);
}
const body = bodySchema.safeParse(rawParsedJson);
if (!body.success) {
return await wrapResponse(
request,
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
corsStrategy !== "none"
);
}
parsedBody = body.data;
}
const apiVersion = getApiVersion(request);
// `context` resolves the target org/project so the plugin can compute the
// caller's role floor for the cap intersection (see the loader builder).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
let authenticationResult: PersonalAccessTokenAuthenticationResult;
let ability: RbacAbility;
const bearer = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
if (bearer && isUserActorToken(bearer)) {
const uatAuth = await rbac.authenticateUserActor(request, ctx);
if (!uatAuth.ok) {
return await wrapResponse(
request,
json({ error: uatAuth.error }, { status: uatAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: uatAuth.userId };
ability = uatAuth.ability;
} else {
const patAuth = await rbac.authenticatePat(request, ctx);
if (!patAuth.ok) {
return await wrapResponse(
request,
json({ error: patAuth.error }, { status: patAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: patAuth.userId };
ability = patAuth.ability;
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
}
if (authorization) {
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);
if (!checkAuth(ability, authorization.action, $resource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
// PAT auth carries `userId` but no environment — enrich the scope the
// Express middleware established so Sentry events get user attribution.
tenantContext.enrich({ userId: authenticationResult.userId });
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
ability,
request,
apiVersion,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
logBoundaryError("Error in action", error, request.url);
// Typed validation errors map to their own status (default 400);
// logBoundaryError already classified them as expected (no Sentry).
if (error instanceof ServiceValidationError) {
return await wrapResponse(
request,
json({ error: error.message }, { status: error.status ?? 400 }),
corsStrategy !== "none"
);
}
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}
type ApiKeyActionRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
@@ -68,46 +68,52 @@
"WaitpointTag.project"
],
"totals": {
"violations": 0,
"detectorI": 0,
"violations": 4,
"detectorI": 4,
"detectorII": 0,
"detectorIII": 0,
"write": 0,
"read": 0,
"files": 0,
"legacyAnnotations": 5
"read": 4,
"files": 1,
"legacyAnnotations": 0
},
"violations": [],
"legacyAnnotations": [
"violations": [
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 302,
"reason": "OOM machine bump on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 88,
"model": "BatchTaskRun",
"delegate": "batchTaskRun",
"callKind": "read",
"detector": "i",
"snippet": "const batchRun = await this._replica.batchTaskRun.findFirst({"
},
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 360,
"reason": "error on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 149,
"model": "BatchTaskRun",
"delegate": "batchTaskRun",
"callKind": "read",
"detector": "i",
"snippet": "client.batchTaskRun.findFirst({"
},
{
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
"line": 604,
"reason": "RETRYING status on attempt-owning V1/cuid run",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 183,
"model": "TaskRun",
"delegate": "taskRun",
"callKind": "read",
"detector": "i",
"snippet": "const newRows = (await newClient.taskRun.findMany({"
},
{
"file": "apps/webapp/app/v3/services/createTaskRunAttempt.server.ts",
"line": 168,
"reason": "run-bump inside legacy attempt-create tx (V1-only path)",
"receiver": "tx"
},
{
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
"line": 92,
"reason": "post ownerEngine()!==\"NEW\" filter",
"receiver": "runOpsLegacyPrisma"
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
"line": 195,
"model": "TaskRun",
"delegate": "taskRun",
"callKind": "read",
"detector": "i",
"snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({"
}
]
],
"legacyAnnotations": []
}
@@ -7,6 +7,7 @@ import { packetRequiresOffloading, parsePacket } from "@trigger.dev/core/v3";
import type { BatchTaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { isUniqueConstraintError, Prisma } from "@trigger.dev/database";
import type { RunStore } from "@internal/run-store";
import pMap from "p-map";
import { z } from "zod";
import type { PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
@@ -32,6 +33,16 @@ import { BaseService, ServiceValidationError } from "./baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server";
const PROCESSING_BATCH_SIZE = 50;
const IDEMPOTENCY_KEY_LOOKUP_CHUNK_SIZE = 50;
const IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY = 5;
function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
const MAX_ATTEMPTS = 10;
@@ -397,28 +408,31 @@ export class BatchTriggerV3Service extends BaseService {
itemsByTask,
});
// Fetch cached runs for each task identifier separately to make use of the index
const cachedRuns = await Promise.all(
Object.entries(itemsByTask).map(([taskIdentifier, items]) =>
this.runStore.findRuns(
{
where: {
runtimeEnvironmentId: environment.id,
taskIdentifier,
idempotencyKey: {
in: items.map((i) => i.options?.idempotencyKey).filter(Boolean),
},
},
select: {
friendlyId: true,
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
},
},
this._prisma
const idempotencyKeyLookups = Object.entries(itemsByTask).flatMap(([taskIdentifier, items]) => {
const idempotencyKeys = Array.from(
new Set(
items.map((i) => i.options?.idempotencyKey).filter((key): key is string => Boolean(key))
)
);
return chunkArray(idempotencyKeys, IDEMPOTENCY_KEY_LOOKUP_CHUNK_SIZE).map((chunk) => ({
taskIdentifier,
idempotencyKeys: chunk,
}));
});
const cachedRuns = (
await pMap(
idempotencyKeyLookups,
async ({ taskIdentifier, idempotencyKeys }) => {
const rows = await this.runStore.findRunsByIdempotencyKeys(
{ runtimeEnvironmentId: environment.id, taskIdentifier, idempotencyKeys },
this._prisma
);
return rows.map((row) => ({ ...row, taskIdentifier }));
},
{ concurrency: IDEMPOTENCY_KEY_LOOKUP_CONCURRENCY }
)
).then((results) => results.flat());
).flat();
// Build the run IDs in order: reuse an unexpired cached id, else mint a new id (and record any
// expired cached id so its idempotency key can be cleared below).
@@ -426,7 +440,9 @@ export class BatchTriggerV3Service extends BaseService {
const runs = await Promise.all(
body.items.map(async (item) => {
const cachedRun = cachedRuns.find((r) => r.idempotencyKey === item.options?.idempotencyKey);
const cachedRun = cachedRuns.find(
(r) => r.taskIdentifier === item.task && r.idempotencyKey === item.options?.idempotencyKey
);
if (cachedRun) {
if (cachedRun.idempotencyKeyExpiresAt && cachedRun.idempotencyKeyExpiresAt < new Date()) {
@@ -1,4 +1,5 @@
import { RunEngineVersion, type TaskRun } from "@trigger.dev/database";
import { runOpsLegacyPrismaClient } from "~/db.server";
import { engine } from "../runEngine.server";
import { isCancellableRunStatus } from "../taskStatus";
import { BaseService } from "./baseService.server";
@@ -43,7 +44,7 @@ export class CancelTaskRunService extends BaseService {
// DB row directly. Never throw here: the cancel route returns 500 on any throw.
if (!isCancellableRunStatus(taskRun.status)) {
if (options?.bulkActionId) {
await this._prisma.taskRun.update({
await runOpsLegacyPrismaClient.taskRun.update({
where: { id: taskRun.id },
data: { bulkActionGroupIds: { push: options.bulkActionId } },
});
@@ -51,7 +52,7 @@ export class CancelTaskRunService extends BaseService {
return { id: taskRun.id, alreadyFinished: true };
}
await this._prisma.taskRun.update({
await runOpsLegacyPrismaClient.taskRun.update({
where: { id: taskRun.id },
data: {
status: "CANCELED",
@@ -2,6 +2,7 @@ import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
export class ResetIdempotencyKeyService extends BaseService {
public async call(
@@ -9,12 +10,27 @@ export class ResetIdempotencyKeyService extends BaseService {
taskIdentifier: string,
authenticatedEnv: AuthenticatedEnvironment
): Promise<{ id: string }> {
// The predicate has no run id to route by. When the env mints run-ops ids its runs live on NEW,
// so pin the reset to NEW and skip the wrong-DB (0-row) write to the draining legacy DB. Resolve
// this only when the org (and its flags) is loaded on the env — which the authenticated API path
// always provides; otherwise fall back to the two-store reset (correct, just not optimized).
let residency: "NEW" | "LEGACY" = "LEGACY";
if (authenticatedEnv.organization) {
const mintKind = await resolveRunIdMintKind({
organizationId: authenticatedEnv.organizationId,
id: authenticatedEnv.id,
orgFeatureFlags: authenticatedEnv.organization.featureFlags,
});
residency = mintKind === "runOpsId" ? "NEW" : "LEGACY";
}
const { count: pgCount } = await this.runStore.clearIdempotencyKey(
{
byPredicate: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
residency,
},
},
this._prisma
@@ -80,6 +96,7 @@ export class ResetIdempotencyKeyService extends BaseService {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
residency,
},
},
this._prisma
+145 -1
View File
@@ -45,6 +45,41 @@ const RUNOPS_SCHEMA = path.join(
);
const BASELINE_PATH = path.join(WEBAPP_DIR, "app", "v3", "runOpsMigration", "track1-baseline.json");
const CP_PACKAGE_DIR = path.dirname(path.dirname(CP_SCHEMA));
const RUNOPS_PACKAGE_DIR = path.dirname(path.dirname(RUNOPS_SCHEMA));
/** Absolute path of the generated Prisma client for a schema, from its generator `output = "..."`. */
function generatedClientDir(schemaFile: string): string {
const m = /^\s*output\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(schemaFile, "utf8"));
if (!m) {
console.error(`No generator output path found in ${schemaFile}`);
process.exit(2);
}
return path.resolve(path.dirname(schemaFile), m[1]);
}
const CP_GENERATED_DIR = generatedClientDir(CP_SCHEMA);
const RUNOPS_GENERATED_DIR = generatedClientDir(RUNOPS_SCHEMA);
function realpathIfExists(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
// Classification roots, realpath'd to match the checker's realpath'd file names.
const RUNOPS_DECL_DIRS = [RUNOPS_GENERATED_DIR, RUNOPS_PACKAGE_DIR].map(realpathIfExists);
const CP_DECL_DIRS = [CP_GENERATED_DIR, CP_PACKAGE_DIR].map(realpathIfExists);
// Both client packages are force-resolved to SOURCE (src/ + generated client) when building the
// program, so classification never depends on a built/fresh `dist/` in the running environment.
const FORCED_TYPE_RESOLUTIONS = new Map<string, string>([
["@trigger.dev/database", path.join(CP_PACKAGE_DIR, "src", "index.ts")],
["@internal/run-ops-database", path.join(RUNOPS_PACKAGE_DIR, "src", "index.ts")],
]);
// Files excluded from the sweep. V1-only files come from .claude/rules/legacy-v3-code.md.
const V1_FILES = new Set(
[
@@ -254,7 +289,41 @@ function buildProgram(): ts.Program {
};
const parsed = ts.getParsedCommandLineOfConfigFile(TSCONFIG_PATH, undefined, host);
if (!parsed) throw new Error(`Failed to parse ${TSCONFIG_PATH}`);
return ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
// Resolve workspace packages from source (tsconfig.check.json blanks this to target built dists,
// which may not exist here), and pin the two client packages to their src entrypoints —
// @trigger.dev/database has no exports map, so the condition alone cannot redirect it.
const options: ts.CompilerOptions = {
...parsed.options,
customConditions: ["@triggerdotdev/source"],
};
const compilerHost = ts.createCompilerHost(options);
const resolutionCache = ts.createModuleResolutionCache(
compilerHost.getCurrentDirectory(),
(f) => compilerHost.getCanonicalFileName(f),
options
);
compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirected, opts) =>
moduleLiterals.map((lit) => {
const forced = FORCED_TYPE_RESOLUTIONS.get(lit.text);
if (forced) {
return {
resolvedModule: {
resolvedFileName: forced,
extension: ts.Extension.Ts,
isExternalLibraryImport: false,
},
};
}
return ts.resolveModuleName(
lit.text,
containingFile,
opts,
compilerHost,
resolutionCache,
redirected
);
});
return ts.createProgram({ rootNames: parsed.fileNames, options, host: compilerHost });
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -263,7 +332,17 @@ function buildProgram(): ts.Program {
type ClientKind = "cp" | "runops" | "other";
function underDir(file: string, dir: string): boolean {
const rel = path.relative(dir, file);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Absolute-dir match against the two packages first (environment-independent); substring match is
// only a fallback for non-workspace layouts (pnpm store paths) and the virtual self-test files.
function declFileKind(fileName: string): ClientKind | undefined {
const abs = path.resolve(fileName);
if (RUNOPS_DECL_DIRS.some((d) => underDir(abs, d))) return "runops";
if (CP_DECL_DIRS.some((d) => underDir(abs, d))) return "cp";
const f = fileName.split(path.sep).join("/");
if (f.includes("run-ops-database")) return "runops";
if (f.includes("internal-packages/database")) return "cp";
@@ -629,9 +708,65 @@ function collectCrossSeamHits(
// Scan
// ─────────────────────────────────────────────────────────────────────────────
// Known-kind receivers in db.server.ts. If either fails to classify, the checker cannot see the
// client types (e.g. generated clients missing) and every verdict would be a silent false-clean,
// so the guard refuses to report instead of rubber-stamping.
const CLASSIFICATION_ANCHORS: Array<{ name: string; expected: ClientKind }> = [
{ name: "runOpsLegacyPrisma", expected: "cp" },
{ name: "runOpsNewPrismaClient", expected: "runops" },
];
function assertClassificationAnchors(program: ts.Program, checker: ts.TypeChecker): void {
const anchorFile = path.join(WEBAPP_DIR, "app", "db.server.ts");
const sf = program.getSourceFile(anchorFile);
const problems: string[] = [];
if (!sf) {
problems.push(`${repoRel(anchorFile)} is not part of the program`);
} else {
for (const anchor of CLASSIFICATION_ANCHORS) {
let ident: ts.Identifier | undefined;
const find = (node: ts.Node): void => {
if (ident) return;
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === anchor.name
) {
ident = node.name;
return;
}
ts.forEachChild(node, find);
};
find(sf);
if (!ident) {
problems.push(`anchor "${anchor.name}" not found in ${repoRel(anchorFile)}`);
continue;
}
const receiverType = checker.getTypeAtLocation(ident);
const delegateSym = receiverType.getProperty("taskRun");
const got = delegateSym
? classifyType(checker, checker.getTypeOfSymbolAtLocation(delegateSym, ident))
: "unresolved";
if (got !== anchor.expected) {
problems.push(`${anchor.name}.taskRun classified "${got}", expected "${anchor.expected}"`);
}
}
}
if (problems.length) {
console.error(
`[runops-guard] CLASSIFICATION ANCHORS FAILED — the guard cannot distinguish the ` +
`control-plane and run-ops clients in this environment, so its verdicts would be ` +
`meaningless:\n ${problems.join("\n ")}\n` +
`Ensure the generated Prisma clients exist (pnpm run generate) and retry.`
);
process.exit(2);
}
}
function scan(): ScanResult {
const program = buildProgram();
const checker = program.getTypeChecker();
assertClassificationAnchors(program, checker);
return scanProgram(program, checker, isInScope);
}
@@ -1134,6 +1269,15 @@ function main(): void {
process.exit(2);
}
for (const dir of [CP_GENERATED_DIR, RUNOPS_GENERATED_DIR]) {
if (!fs.existsSync(path.join(dir, "index.d.ts"))) {
console.error(
`[runops-guard] generated Prisma client missing at ${repoRel(dir)} — run: pnpm run generate`
);
process.exit(2);
}
}
console.error(`[runops-guard] repo root: ${REPO_ROOT}`);
console.error(
`[runops-guard] run-graph delegates: ${Array.from(RUN_GRAPH_DELEGATES).join(", ")}`
+9
View File
@@ -134,6 +134,15 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
if (process.env.HTTP_SERVER_DISABLED !== "true") {
// Back-compat shim: a previously-deployed client build polls this endpoint after a
// /build asset 404 and reloads once it reports a newer build id, letting those older
// tabs recover in a single reload. Temporary — safe to remove once older clients have
// churned out. Deliberately does NOT set an X-Build-Id response header.
app.get("/build-version", (_req, res) => {
res.set("Cache-Control", "no-store");
res.json({ version: build.assets.version });
});
const socketIo: { io: IoServer } | undefined = build.entry.module.socketIo;
const wss: WebSocketServer | undefined = build.entry.module.wss;
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
+226
View File
@@ -2910,4 +2910,230 @@ describe("API", () => {
});
});
});
// PAT *action* routes (createActionPATApiRoute). Target: PUT
// /api/v1/projects/:projectRef/default-region — the first consumer of the
// new mutation builder. Exercises the method guard, body parsing, PAT auth,
// and the membership floor. The manage:project authorization block can't be
// driven to a 403 here: the OSS fallback ability is permissive
// (can: () => true), so role-based denial only bites with the cloud plugin
// loaded — that path is covered by the plugin's own tests.
describe("Default region — PAT action route", () => {
const pathFor = (ref: string) => `/api/v1/projects/${ref}/default-region`;
const putRegion = (path: string, headers: Record<string, string>, body?: unknown) =>
getTestServer().webapp.fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
it("missing Authorization: 401", async () => {
const res = await putRegion(pathFor("proj_nope"), {}, { region: "aws-us-east-1" });
expect(res.status).toBe(401);
});
it("non-PAT token: 401", async () => {
const res = await putRegion(
pathFor("proj_nope"),
{ Authorization: "Bearer not-a-real-token" },
{ region: "aws-us-east-1" }
);
expect(res.status).toBe(401);
});
it("revoked PAT: 401", async () => {
const server = getTestServer();
const { user } = await seedTestUserProject(server.prisma);
const revoked = await seedTestPAT(server.prisma, user.id, { revoked: true });
const res = await putRegion(
pathFor("proj_nope"),
{ Authorization: `Bearer ${revoked.token}` },
{ region: "aws-us-east-1" }
);
expect(res.status).toBe(401);
});
it("wrong method (POST): 405", async () => {
// A valid token is needed to clear the global api rate-limit middleware
// (it 401s unauthenticated /api requests before the route runs); the
// builder's method guard then rejects the non-PUT with 405.
const server = getTestServer();
const { pat } = await seedTestUserProject(server.prisma);
const res = await getTestServer().webapp.fetch(pathFor("proj_nope"), {
method: "POST",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ region: "aws-us-east-1" }),
});
expect(res.status).toBe(405);
});
it("valid PAT, empty body: 400", async () => {
const server = getTestServer();
const { project, pat } = await seedTestUserProject(server.prisma);
const res = await getTestServer().webapp.fetch(pathFor(project.externalRef), {
method: "PUT",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
});
expect(res.status).toBe(400);
});
it("valid PAT, body missing region: 400", async () => {
const server = getTestServer();
const { project, pat } = await seedTestUserProject(server.prisma);
const res = await putRegion(
pathFor(project.externalRef),
{ Authorization: `Bearer ${pat.token}` },
{}
);
expect(res.status).toBe(400);
});
it("valid PAT, project in another user's org: 404 (membership floor)", async () => {
const server = getTestServer();
const a = await seedTestUserProject(server.prisma);
const b = await seedTestUserProject(server.prisma);
const res = await putRegion(
pathFor(b.project.externalRef),
{ Authorization: `Bearer ${a.pat.token}` },
{ region: "aws-us-east-1" }
);
expect(res.status).toBe(404);
});
it("valid PAT, own project, unknown region: auth passes, handler runs", async () => {
const server = getTestServer();
const { project, pat } = await seedTestUserProject(server.prisma);
const res = await putRegion(
pathFor(project.externalRef),
{ Authorization: `Bearer ${pat.token}` },
{ region: "definitely-not-a-region" }
);
// No worker groups seeded → the presenter throws → the route returns 400.
// The point: the builder let the request through to the handler (auth +
// method + body all passed).
expect(res.status).toBe(400);
});
});
// Member removal via the PAT action route. The last-member guard lives in
// removeTeamMember and surfaces as a ServiceValidationError the builder maps
// to 400 — so an org can't be emptied of its only member.
describe("Member removal — last-member guard", () => {
it("removing the last member is rejected: 400", async () => {
const server = getTestServer();
const { user, organization, pat } = await seedTestUserProject(server.prisma);
const member = await server.prisma.orgMember.findFirst({
where: { organizationId: organization.id, userId: user.id },
});
if (!member) throw new Error("seed did not create an org member");
const res = await server.webapp.fetch(
`/api/v1/orgs/${organization.id}/members/${member.id}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${pat.token}` } }
);
expect(res.status).toBe(400);
});
});
// Multi-method PAT action routes declare their allowed verbs, so a verb they
// don't handle (e.g. POST on a PATCH/DELETE route) is rejected rather than
// falling through to the rename branch.
describe("Multi-method routes reject other verbs", () => {
it("POST to the org rename/delete route: 405", async () => {
const server = getTestServer();
const { organization, pat } = await seedTestUserProject(server.prisma);
const res = await server.webapp.fetch(`/api/v1/orgs/${organization.id}`, {
method: "POST",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "New name" }),
});
expect(res.status).toBe(405);
});
});
// Re-invite is idempotent: an already-invited email is skipped (not created,
// not re-emailed) and reported as alreadyInvited, so a repeat call neither
// 500s (P2002) nor sends a duplicate invite email.
describe("Member invites — re-invite is idempotent", () => {
it("inviting the same email twice: 201 then 200, second reports alreadyInvited", async () => {
const server = getTestServer();
const { organization, pat } = await seedTestUserProject(server.prisma);
const path = `/api/v1/orgs/${organization.id}/invites`;
const email = "dup-invite@example.com";
const invite = () =>
server.webapp.fetch(path, {
method: "POST",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ emails: [email] }),
});
const first = await invite();
expect(first.status).toBe(201);
const firstBody = (await first.json()) as { invited: { email: string }[] };
expect(firstBody.invited.map((i) => i.email)).toContain(email);
const second = await invite();
expect(second.status).toBe(200);
const secondBody = (await second.json()) as {
invited: { email: string }[];
alreadyInvited: string[];
};
expect(secondBody.invited).toHaveLength(0);
expect(secondBody.alreadyInvited).toContain(email);
});
it("re-inviting an email another org member already invited reports alreadyInvited", async () => {
const server = getTestServer();
const { organization, pat } = await seedTestUserProject(server.prisma);
// A different user invited this email first: the create hits P2002, so it's
// skipped (not re-created, not re-emailed) and surfaced as alreadyInvited
// rather than being reported as a fresh invite.
const otherUser = await server.prisma.user.create({
data: {
email: `other_${organization.id}@example.com`,
authenticationMethod: "MAGIC_LINK",
},
});
const sharedEmail = `shared_${organization.id}@example.com`;
await server.prisma.orgMemberInvite.create({
data: {
email: sharedEmail,
token: `tok_${Math.random().toString(36).slice(2)}`,
organizationId: organization.id,
inviterId: otherUser.id,
role: "MEMBER",
},
});
const res = await server.webapp.fetch(`/api/v1/orgs/${organization.id}/invites`, {
method: "POST",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ emails: [sharedEmail] }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
invited: { email: string }[];
alreadyInvited: string[];
};
expect(body.invited).toHaveLength(0);
expect(body.alreadyInvited).toContain(sharedEmail);
});
});
// Org creation via the management API is gated behind
// ORG_CREATION_API_ENABLED (default "0"). Without it, a valid PAT gets a 404
// so the endpoint stays invisible.
describe("Org creation — disabled by default", () => {
it("POST /api/v1/orgs with a valid PAT returns 404 when the flag is unset", async () => {
const server = getTestServer();
const { pat } = await seedTestUserProject(server.prisma);
const res = await server.webapp.fetch("/api/v1/orgs", {
method: "POST",
headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ title: "New org from API" }),
});
expect(res.status).toBe(404);
});
});
});
@@ -309,9 +309,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
}
);
// Split scan merge serves new + legacy in one keyset-ordered page.
// Split scan merge serves new + legacy in one createdAt-ordered page; legacy is always read.
heteroPostgresTest(
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
"split scan merges new (PG17) + legacy (PG14) rows under the createdAt keyset order; legacy always read",
async ({ prisma14, prisma17 }) => {
const ctx14 = await seedParents(prisma14, "merge");
await mirrorEnvParents(prisma17, ctx14, "merge");
@@ -323,7 +323,8 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
// Case A: small page fully served by new alone => legacy NOT read.
// Case A: always-merge — legacy is read even when new could fill the page (the old skip was
// unsound across the residency split). Page is the createdAt-ordered union of both DBs.
const legacySpyA = spyClient(prisma14);
const presenterA = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
@@ -332,9 +333,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
splitEnabled: true,
});
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
expect(legacySpyA.counts.findMany).toBe(0);
// union newest-first (createdAt, insertion order a<b<c<d<e): e, d, c, b, a -> page of 2 = e, d.
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d"]);
expect(legacySpyA.counts.findMany).toBeGreaterThan(0);
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
const legacySpyB = spyClient(prisma14);
@@ -348,8 +349,10 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
// union DESC of all 5: e, d, c, b, a -> first 4.
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
expect(pageB.pagination.next).toBe("batch_b");
// cursor parity: next is the FULL composite (createdAt, id) cursor of the 4th row (batch_b),
// previous undefined. Assert the complete value so a bad timestamp prefix can't pass.
const bRow = pageB.batches.find((b) => b.id === "batch_b")!;
expect(pageB.pagination.next).toBe(`${new Date(bRow.createdAt).getTime()}_batch_b`);
expect(pageB.pagination.previous).toBeUndefined();
expect(pageB.hasAnyBatches).toBe(true);
}
@@ -436,7 +439,11 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
const hasMore = direct.length > 2;
const expectedPage = direct.slice(0, 2);
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
expect(
hasMore
? page.pagination.next === `${expectedPage[1].createdAt.getTime()}_${expectedPage[1].id}`
: !page.pagination.next
).toBe(true);
expect(page.pagination.previous).toBeUndefined();
expect(page.hasAnyBatches).toBe(true);
@@ -453,6 +460,138 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P
}
);
// REGRESSION: a flipped org's real id mix. A cuid ("c"=0x63) sorts ABOVE a run-ops id ("0"=0x30)
// under `id DESC`, so pre-flip legacy batches belong at the top — but #scanBatchTaskRun reads new
// first, skips legacy once the page is full, and `id < cursor` can never reach a "c…" from a "0…"
// cursor. Net: pre-flip legacy batches become unreachable.
heteroPostgresTest(
"flipped org: pre-flip legacy (cuid) batches remain reachable alongside post-flip run-ops batches",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "flip");
await mirrorEnvParents(prisma17, ctx, "flip");
// Pre-flip cuid batch on legacy (sorts highest); post-flip run-ops batches on new (sort below).
const LEGACY_CUID = "cm0preflipbatch0000000001";
await createBatch(prisma14, ctx, { id: LEGACY_CUID, friendlyId: "fr_preflip", runCount: 9 });
const NEW_RUNOPS = [
"06fnewbatch00000000000000a",
"06fnewbatch00000000000000b",
"06fnewbatch00000000000000c",
];
for (const id of NEW_RUNOPS) {
await createBatch(prisma17, ctx, { id, friendlyId: `fr_${id.slice(-1)}`, runCount: 1 });
}
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
// The pre-flip cuid batch is the oldest, so under newest-first it lands on a later page — but it
// must be REACHABLE by paging forward, not stranded behind the run-ops ids (the skip + id-order
// bug dropped it entirely: `id < <run-ops cursor>` never matches a "c…" id).
const seen = new Set<string>();
let cursor: string | undefined = undefined;
for (let i = 0; i < 10; i++) {
const page = await presenter.call(
baseCall(ctx, { pageSize: 2, cursor, direction: "forward" })
);
page.batches.forEach((b) => seen.add(b.id));
if (!page.pagination.next) break;
cursor = page.pagination.next;
}
expect([...seen]).toContain(LEGACY_CUID);
}
);
// REGRESSION (ordering): even with an always-merge fix, keyset-by-id is chronologically wrong across
// the flip — a cuid ("c") sorts above a run-ops id ("0"), so an OLDER pre-flip batch outranks a NEWER
// post-flip batch. The list is "newest first", so the later-created run-ops batch must come first.
heteroPostgresTest(
"flipped org: batches list is newest-first across the flip boundary (by createdAt, not id)",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "order");
await mirrorEnvParents(prisma17, ctx, "order");
const OLD_LEGACY = "cm0oldbatch00000000000001"; // cuid, created EARLIER
const NEW_RUNOPS = "06fnewbatch000000000000001"; // run-ops, created LATER
await createBatch(prisma14, ctx, {
id: OLD_LEGACY,
friendlyId: "fr_old",
createdAt: new Date(Date.now() - 3_600_000),
});
await createBatch(prisma17, ctx, {
id: NEW_RUNOPS,
friendlyId: "fr_new",
createdAt: new Date(),
});
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
// Newest-first: the later-created run-ops batch outranks the older legacy one.
expect(page.batches.map((b) => b.id)).toEqual([NEW_RUNOPS, OLD_LEGACY]);
}
);
// Overlap regression: batches duplicated on BOTH stores (mid-migration copies) must de-dupe to one
// each without dropping rows or underfilling pages. Proves the merge needs no post-dedup refill:
// the union of each store's top-(pageSize+1) always contains the global top, and the next page
// re-queries both stores from the cursor.
heteroPostgresTest(
"flipped org: batches duplicated across both stores de-dupe without dropping rows across pagination",
async ({ prisma14, prisma17 }) => {
const ctx = await seedParents(prisma14, "ovl");
await mirrorEnvParents(prisma17, ctx, "ovl");
const at = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000);
// a..e newest->oldest. a,b,c live on BOTH DBs (dup); d,e only on legacy.
const rows = [
{ id: "batch_ov_a", both: true, s: 1 },
{ id: "batch_ov_b", both: true, s: 2 },
{ id: "batch_ov_c", both: true, s: 3 },
{ id: "batch_ov_d", both: false, s: 4 },
{ id: "batch_ov_e", both: false, s: 5 },
];
for (const r of rows) {
// A dup is a row COPY: identical (createdAt, id) on both DBs. Compute createdAt ONCE so both
// copies match exactly (two at(r.s) calls would drift by ms and the older copy would re-surface
// on the next page under the createdAt keyset).
const createdAt = at(r.s);
await createBatch(prisma14, ctx, { id: r.id, friendlyId: `fr_${r.id}`, createdAt });
if (r.both) {
await createBatch(prisma17, ctx, { id: r.id, friendlyId: `fr_${r.id}`, createdAt });
}
}
const seen: string[] = [];
let cursor: string | undefined = undefined;
for (let i = 0; i < 10; i++) {
const presenter = new BatchListPresenter(prisma17, prisma17, {
runOpsNew: prisma17,
runOpsLegacyReplica: prisma14,
controlPlaneReplica: prisma14,
splitEnabled: true,
});
const page = await presenter.call(
baseCall(ctx, { pageSize: 2, cursor, direction: "forward" })
);
seen.push(...page.batches.map((b) => b.id));
if (!page.pagination.next) break;
cursor = page.pagination.next;
}
// Every batch exactly once, newest-first; the three dups collapsed to one each.
expect(seen).toEqual(["batch_ov_a", "batch_ov_b", "batch_ov_c", "batch_ov_d", "batch_ov_e"]);
}
);
heteroRunOpsPostgresTest(
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
async ({ prisma14, prisma17 }) => {
+29
View File
@@ -155,4 +155,33 @@ describe("removeTeamMember", () => {
).rejects.toThrow("Member not found in this organization");
}
);
containerTest(
"refuses to remove the sole member (last-member guard, locks the message)",
async ({ prisma }) => {
const slug = `orgsolo_${Math.random().toString(36).slice(2, 10)}`;
const soloUser = await prisma.user.create({
data: { email: `solo_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
});
const organization = await prisma.organization.create({
data: {
title: slug,
slug,
members: { create: { userId: soloUser.id, role: "ADMIN" } },
},
include: { members: true },
});
const soloMember = organization.members[0];
await expect(
removeTeamMember(
{ userId: soloUser.id, slug: organization.slug, memberId: soloMember.id },
prisma
)
).rejects.toThrow("Cannot remove the last member of an organization");
const stillThere = await prisma.orgMember.findUnique({ where: { id: soloMember.id } });
expect(stillThere).not.toBeNull();
}
);
});
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_IMAGE=node:22.23.1-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4
ARG NODE_IMAGE=node:24.18.0-bookworm-slim@sha256:39a4259b6f744868a8228742ad45aa3026f97302e5eec2fa4a38b30ca0a66e12
FROM golang:1.26-alpine AS goose_builder
RUN go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
+1 -1
View File
@@ -27,7 +27,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.20.0",
"@types/node": "^24.13.3",
"rimraf": "^6.0.1",
"ts-proto": "^1.167.3"
},
+1 -1
View File
@@ -10,7 +10,7 @@
},
"devDependencies": {
"@trigger.dev/database": "workspace:*",
"@types/node": "^22.20.0",
"@types/node": "^24.13.3",
"rimraf": "6.0.1"
},
"scripts": {
@@ -1685,6 +1685,7 @@ export class RunEngine {
idempotencyKeyExpiresAt,
timeout,
tags,
standaloneResidency,
}: {
/** The run that will block on this waitpoint. Co-locates the waitpoint with the run's DB. */
runId?: string;
@@ -1694,6 +1695,8 @@ export class RunEngine {
idempotencyKeyExpiresAt?: Date;
timeout?: Date;
tags?: string[];
/** Standalone-token residency (no owning run) from the env mint kind; ignored when `runId` is set. */
standaloneResidency?: "NEW" | "LEGACY";
}): Promise<{ waitpoint: Waitpoint; isCached: boolean }> {
return this.waitpointSystem.createManualWaitpoint({
runId,
@@ -1703,6 +1706,7 @@ export class RunEngine {
idempotencyKeyExpiresAt,
timeout,
tags,
standaloneResidency,
});
}
@@ -597,6 +597,7 @@ export class DequeueSystem {
id: result.worker.id,
friendlyId: result.worker.friendlyId,
version: result.worker.version,
runtime: result.worker.runtime ?? undefined,
},
// TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
// Would help make the typechecking stricter
@@ -126,10 +126,13 @@ function enhanceExecutionSnapshotWithWaitpoints(
async function getSnapshotWaitpointIds(
prisma: PrismaClientOrTransaction,
snapshotId: string,
runStore?: RunStore
runStore?: RunStore,
// The owning run id, so the router can route to the run's store (the completed-waitpoint join
// co-locates with the snapshot/run) instead of fanning out to both run-ops DBs.
runId?: string
): Promise<string[]> {
if (runStore) {
return runStore.findSnapshotCompletedWaitpointIds(snapshotId, prisma);
return runStore.findSnapshotCompletedWaitpointIds(snapshotId, prisma, runId);
}
const result = await prisma.$queryRaw<{ B: string }[]>`
@@ -144,10 +147,12 @@ async function getSnapshotWaitpointIds(
async function getSnapshotWaitpointIdsWithPresence(
prisma: PrismaClientOrTransaction,
snapshotId: string,
runStore?: RunStore
runStore?: RunStore,
// The owning run id, so the router can route to the run's store instead of fanning out.
runId?: string
): Promise<{ present: boolean; ids: string[] }> {
if (runStore) {
return runStore.findSnapshotCompletedWaitpointIdsWithPresence(snapshotId, prisma);
return runStore.findSnapshotCompletedWaitpointIdsWithPresence(snapshotId, prisma, runId);
}
const rows = await prisma.$queryRaw<{ id: string; B: string | null }[]>`
@@ -170,7 +175,10 @@ async function getSnapshotWaitpointIdsWithPresence(
async function fetchWaitpointsInChunks(
prisma: PrismaClientOrTransaction,
waitpointIds: string[],
runStore?: RunStore
runStore?: RunStore,
// The owning run id, so the router routes to the run's store and only falls back to the other DB
// for the rare cross-tree token, instead of fanning every chunk out to both run-ops DBs.
runId?: string
): Promise<Waitpoint[]> {
if (waitpointIds.length === 0) return [];
@@ -178,7 +186,7 @@ async function fetchWaitpointsInChunks(
for (let i = 0; i < waitpointIds.length; i += WAITPOINT_CHUNK_SIZE) {
const chunk = waitpointIds.slice(i, i + WAITPOINT_CHUNK_SIZE);
const waitpoints = runStore
? await runStore.findManyWaitpoints({ where: { id: { in: chunk } } }, prisma)
? await runStore.findManyWaitpoints({ where: { id: { in: chunk } } }, prisma, runId)
: await prisma.waitpoint.findMany({
where: { id: { in: chunk } },
});
@@ -347,7 +355,8 @@ export async function getExecutionSnapshotsSince(
const { present, ids } = await getSnapshotWaitpointIdsWithPresence(
prisma,
latestSnapshot.id,
runStore
runStore,
runId
);
let waitpointIds = ids;
@@ -356,7 +365,7 @@ export async function getExecutionSnapshotsSince(
// authoritative - re-read from the primary so the runner is not handed a waitpoint-less continue
// (which it silently drops, hanging the run). Single-reader replicas never hit this (present stays true).
if (repairClient && repairClient !== prisma && !present) {
waitpointIds = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
waitpointIds = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore, runId);
readClient = repairClient;
}
@@ -369,7 +378,12 @@ export async function getExecutionSnapshotsSince(
// poll even against a caught-up replica.
const expectedCount = new Set(latestSnapshot.completedWaitpointOrder ?? []).size;
if (repairClient && repairClient !== prisma && waitpointIds.length < expectedCount) {
const repaired = await getSnapshotWaitpointIds(repairClient, latestSnapshot.id, runStore);
const repaired = await getSnapshotWaitpointIds(
repairClient,
latestSnapshot.id,
runStore,
runId
);
if (repaired.length > waitpointIds.length) {
waitpointIds = repaired;
readClient = repairClient;
@@ -377,7 +391,7 @@ export async function getExecutionSnapshotsSince(
}
// Step 4: Fetch waitpoints in chunks to avoid NAPI string conversion limits
const waitpoints = await fetchWaitpointsInChunks(readClient, waitpointIds, runStore);
const waitpoints = await fetchWaitpointsInChunks(readClient, waitpointIds, runStore, runId);
// Step 5: Build enhanced snapshots - only latest gets waitpoints, others get empty arrays
// The runner only uses completedWaitpoints from the latest snapshot anyway
@@ -59,9 +59,10 @@ export class WaitpointSystem {
runId: string;
tx?: PrismaClientOrTransaction;
}) {
// Route the delete: a run's edges may live on #new and/or #legacy (mid-drain), so it must fan
// across both stores. The caller's `tx` is not forwarded into either leg — each store's delete
// runs on its own client (the router never threads a control-plane tx into a routed write).
// A run's edges co-locate with the run (the edge write routes by runId), so the router routes this
// taskRunId-keyed delete to the run's store rather than fanning out. The caller's `tx` is not
// forwarded — the delete runs on the owning store's own client (the router never threads a
// control-plane tx into a routed write).
const deleted = await this.$.runStore.deleteManyTaskRunWaitpoints(
{ where: { taskRunId: runId } },
tx
@@ -320,6 +321,7 @@ export class WaitpointSystem {
idempotencyKeyExpiresAt,
timeout,
tags,
standaloneResidency,
}: {
runId?: string;
environmentId: string;
@@ -328,13 +330,22 @@ export class WaitpointSystem {
idempotencyKeyExpiresAt?: Date;
timeout?: Date;
tags?: string[];
// For a STANDALONE token (no owning `runId`): the residency the env's mint kind resolves to, so
// the token lands on the run-ops DB (NEW) in a fully-minted-new deployment instead of defaulting
// to LEGACY by its cuid id-shape. Ignored when `runId` is set (co-location wins).
standaloneResidency?: "NEW" | "LEGACY";
}): Promise<{ waitpoint: Waitpoint; isCached: boolean }> {
// Co-location invariant (see createDateTimeWaitpoint): when a `runId` is supplied the waitpoint
// co-locates with that run's DB and the (env,idempotencyKey) dedup is per-run (co-resident). A
// standalone token (api.v1.waitpoints.tokens.ts) passes no run id — it is created without an
// owner, blocked later by whichever run waits on it (possibly cross-DB, resolved by the
// run-co-resident block edge + completion fan-out), so it routes by id-shape and dedups cross-DB. No tx here.
const colocate = runId ? { coLocateWithRunId: runId } : undefined;
// run-co-resident block edge + completion fan-out). With no owner it reads the env mint kind via
// `standaloneResidency` so a minted-new env keeps its tokens on NEW; unset, it routes by id-shape. No tx here.
const colocate = runId
? { coLocateWithRunId: runId }
: standaloneResidency
? { residency: standaloneResidency }
: undefined;
const existingWaitpoint = idempotencyKey
? await this.$.runStore.findWaitpoint(
{
@@ -506,7 +517,9 @@ export class WaitpointSystem {
// Check if the run is actually blocked using a separate query (see above). Pass the writer so the
// pending re-read is read-your-writes on the owning PRIMARY (a lagging replica can strand the run).
const pendingCount = await this.$.runStore.countPendingWaitpoints($waitpoints, prisma);
// Route by the blocked run id: its blocking waitpoints co-locate with the run, so the router
// counts on the run's store and only falls back to the other DB for a cross-tree token.
const pendingCount = await this.$.runStore.countPendingWaitpoints($waitpoints, prisma, runId);
const isRunBlocked = pendingCount > 0;
@@ -352,7 +352,7 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer {
projectId: parts[3],
envId: parts[5],
queue: parts[7],
concurrencyKey: parts.at(9),
concurrencyKey: parts[9],
};
}
@@ -0,0 +1,120 @@
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect } from "vitest";
import { PostgresRunStore } from "./PostgresRunStore.js";
async function seedEnvironment(prisma: PrismaClient) {
const organization = await prisma.organization.create({
data: { title: "Test Organization", slug: "test-organization" },
});
const project = await prisma.project.create({
data: {
name: "Test Project",
slug: "test-project",
externalRef: "proj_1234",
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "DEVELOPMENT",
slug: "dev",
projectId: project.id,
organizationId: organization.id,
apiKey: "tr_dev_apikey",
pkApiKey: "pk_dev_apikey",
shortcode: "short_code",
},
});
return { organization, project, environment };
}
async function createRun(
prisma: PrismaClient,
params: {
runtimeEnvironmentId: string;
projectId: string;
friendlyId: string;
taskIdentifier: string;
idempotencyKey: string;
idempotencyKeyExpiresAt?: Date;
}
) {
await prisma.taskRun.create({
data: {
friendlyId: params.friendlyId,
taskIdentifier: params.taskIdentifier,
idempotencyKey: params.idempotencyKey,
idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt ?? null,
payload: "{}",
payloadType: "application/json",
runtimeEnvironmentId: params.runtimeEnvironmentId,
projectId: params.projectId,
queue: `task/${params.taskIdentifier}`,
traceId: `trace_${params.friendlyId}`,
spanId: `span_${params.friendlyId}`,
engine: "V2",
},
});
}
describe("PostgresRunStore.findRunsByIdempotencyKeys", () => {
postgresTest("resolves multiple keys, scoped to (env, task)", async ({ prisma }) => {
const { project, environment } = await seedEnvironment(prisma);
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const expiresAt = new Date("2999-01-01T00:00:00.000Z");
await createRun(prisma, {
runtimeEnvironmentId: environment.id,
projectId: project.id,
friendlyId: "run_a1",
taskIdentifier: "task-a",
idempotencyKey: "idem-1",
idempotencyKeyExpiresAt: expiresAt,
});
await createRun(prisma, {
runtimeEnvironmentId: environment.id,
projectId: project.id,
friendlyId: "run_a2",
taskIdentifier: "task-a",
idempotencyKey: "idem-2",
});
await createRun(prisma, {
runtimeEnvironmentId: environment.id,
projectId: project.id,
friendlyId: "run_b1",
taskIdentifier: "task-b",
idempotencyKey: "idem-1",
});
const rows = await store.findRunsByIdempotencyKeys({
runtimeEnvironmentId: environment.id,
taskIdentifier: "task-a",
idempotencyKeys: ["idem-1", "idem-2", "does-not-exist"],
});
const byKey = new Map(rows.map((r) => [r.idempotencyKey, r]));
expect(rows).toHaveLength(2);
expect(byKey.get("idem-1")?.friendlyId).toBe("run_a1");
expect(byKey.get("idem-2")?.friendlyId).toBe("run_a2");
expect(rows.map((r) => r.friendlyId)).not.toContain("run_b1");
expect(byKey.get("idem-1")?.idempotencyKeyExpiresAt).toBeInstanceOf(Date);
expect(byKey.get("idem-1")?.idempotencyKeyExpiresAt?.toISOString()).toBe(
expiresAt.toISOString()
);
expect(byKey.get("idem-2")?.idempotencyKeyExpiresAt).toBeNull();
});
postgresTest("short-circuits on an empty key list without querying", async ({ prisma }) => {
const { environment } = await seedEnvironment(prisma);
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
const rows = await store.findRunsByIdempotencyKeys({
runtimeEnvironmentId: environment.id,
taskIdentifier: "task-a",
idempotencyKeys: [],
});
expect(rows).toEqual([]);
});
});
@@ -19,6 +19,7 @@ import type {
ExpireSnapshotInput,
FinalizeRunData,
ForWaitpointCompletionContext,
IdempotencyKeyRunMatch,
LockRunData,
ReadClient,
RescheduleSnapshotInput,
@@ -71,6 +72,7 @@ export interface RunOpsCapableClient {
// Standalone entity keyed by (environmentId, name); present on both schemas.
waitpointTag: RunOpsDelegate<"upsert" | "findMany">;
$queryRaw: PrismaClient["$queryRaw"];
$queryRawUnsafe: PrismaClient["$queryRawUnsafe"];
$executeRaw: PrismaClient["$executeRaw"];
}
@@ -1682,6 +1684,26 @@ export class PostgresRunStore implements RunStore {
return byId;
}
async findRunsByIdempotencyKeys(
args: { runtimeEnvironmentId: string; taskIdentifier: string; idempotencyKeys: string[] },
client?: ReadClient
): Promise<IdempotencyKeyRunMatch[]> {
if (args.idempotencyKeys.length === 0) {
return [];
}
const prisma = (client ?? this.readOnlyPrisma) as RunOpsCapableClient;
const params: string[] = [];
const branches = args.idempotencyKeys.map((key) => {
const base = params.length;
params.push(args.runtimeEnvironmentId, args.taskIdentifier, key);
return `SELECT "friendlyId", "idempotencyKey", "idempotencyKeyExpiresAt" FROM "TaskRun" WHERE "runtimeEnvironmentId" = $${base + 1} AND "taskIdentifier" = $${base + 2} AND "idempotencyKey" = $${base + 3}`;
});
return prisma.$queryRawUnsafe<IdempotencyKeyRunMatch[]>(
branches.join(" UNION ALL "),
...params
);
}
// --- run-ops persistence ---
async findLatestExecutionSnapshot(
@@ -1875,7 +1897,9 @@ export class PostgresRunStore implements RunStore {
async findSnapshotCompletedWaitpointIds(
snapshotId: string,
client?: ReadClient
client?: ReadClient,
// `runId` selects residency at the router; a single store has one client and ignores it.
_runId?: string
): Promise<string[]> {
const prisma = client ?? this.readOnlyPrisma;
@@ -1902,7 +1926,9 @@ export class PostgresRunStore implements RunStore {
// return the snapshot (via a separate read) while a different, laggier reader returns 0 links.
async findSnapshotCompletedWaitpointIdsWithPresence(
snapshotId: string,
client?: ReadClient
client?: ReadClient,
// `runId` selects residency at the router; a single store has one client and ignores it.
_runId?: string
): Promise<{ present: boolean; ids: string[] }> {
const prisma = client ?? this.readOnlyPrisma;
@@ -2075,7 +2101,12 @@ export class PostgresRunStore implements RunStore {
SELECT COUNT(*) FROM inserted`;
}
async countPendingWaitpoints(waitpointIds: string[], client?: ReadClient): Promise<number> {
async countPendingWaitpoints(
waitpointIds: string[],
client?: ReadClient,
// `runId` selects residency at the router; a single store has one client and ignores it.
_runId?: string
): Promise<number> {
const prisma = client ?? this.readOnlyPrisma;
if (waitpointIds.length === 0) {
@@ -2094,7 +2125,7 @@ export class PostgresRunStore implements RunStore {
WHERE id = ANY(${waitpointIds}::text[])
AND status = 'PENDING'
`;
return Number(pendingCheck.at(0)?.pending_count ?? 0);
return Number(pendingCheck[0]?.pending_count ?? 0);
}
const pendingCheck = await prisma.$queryRaw<{ pending_count: bigint }[]>`
@@ -2103,7 +2134,36 @@ export class PostgresRunStore implements RunStore {
WHERE id IN (${Prisma.join(waitpointIds)})
AND status = 'PENDING'
`;
return Number(pendingCheck.at(0)?.pending_count ?? 0);
return Number(pendingCheck[0]?.pending_count ?? 0);
}
// One `SELECT id, status` returns which ids are PENDING and which exist on this store (any status),
// so the router can route by run id and only re-count the ids ABSENT here on the other DB — without
// undercounting pending (which would prematurely unblock a run) or double-counting a drain-mirrored
// id. Reads only id+status, so it stays cheap for a run's small blocking set.
async countPendingWaitpointsWithPresence(
waitpointIds: string[],
client?: ReadClient
): Promise<{ pendingIds: string[]; presentIds: string[] }> {
const prisma = client ?? this.readOnlyPrisma;
if (waitpointIds.length === 0) {
return { pendingIds: [], presentIds: [] };
}
const rows =
this.schemaVariant === "dedicated"
? await prisma.$queryRaw<{ id: string; status: string }[]>`
SELECT id, status FROM "Waitpoint" WHERE id = ANY(${waitpointIds}::text[])
`
: await prisma.$queryRaw<{ id: string; status: string }[]>`
SELECT id, status FROM "Waitpoint" WHERE id IN (${Prisma.join(waitpointIds)})
`;
return {
pendingIds: rows.filter((r) => r.status === "PENDING").map((r) => r.id),
presentIds: rows.map((r) => r.id),
};
}
async createWaitpoint<T extends Prisma.WaitpointCreateArgs>(
@@ -2167,7 +2227,9 @@ export class PostgresRunStore implements RunStore {
async findManyWaitpoints<T extends Prisma.WaitpointFindManyArgs>(
args: Prisma.SelectSubset<T, Prisma.WaitpointFindManyArgs>,
client?: ReadClient
client?: ReadClient,
// `runId` selects residency at the router; a single store has one client and ignores it.
_runId?: string
): Promise<Prisma.WaitpointGetPayload<T>[]> {
const prisma = client ?? this.readOnlyPrisma;
@@ -2473,7 +2535,9 @@ export class PostgresRunStore implements RunStore {
async upsertWaitpointTag(
data: { environmentId: string; name: string; projectId: string; id?: string },
tx?: PrismaClientOrTransaction
tx?: PrismaClientOrTransaction,
// `residency` selects the store at the router; a single store has one client and ignores it.
_residency?: "NEW" | "LEGACY"
): Promise<WaitpointTag> {
const prisma = tx ?? this.prisma;
@@ -748,7 +748,7 @@ describe("RoutingRunStore never threads a caller tx into either sub-store (recor
);
heteroRunOpsPostgresTest(
"deleteManyTaskRunWaitpoints fan-out hands BOTH sub-stores an undefined tx",
"deleteManyTaskRunWaitpoints routes by taskRunId to the owning store with an undefined tx",
async ({ prisma14, prisma17 }) => {
const legacyCalls: RecordedCall[] = [];
const newCalls: RecordedCall[] = [];
@@ -769,14 +769,15 @@ describe("RoutingRunStore never threads a caller tx into either sub-store (recor
);
await seedLegacyBlockingEdge(prisma14, env, runId, "spy_del");
// Keyed by taskRunId → the both-stores fan-out branch. Pass the base control-plane client as tx.
// Keyed by a classifiable taskRunId (a cuid run → #legacy): routes to the owning store, no
// fan-out. Pass the base control-plane client as tx — it must NOT be threaded into the routed leg.
await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } }, prisma14);
const legacyTx = txArgsFor(legacyCalls, "deleteManyTaskRunWaitpoints");
const newTx = txArgsFor(newCalls, "deleteManyTaskRunWaitpoints");
expect(legacyTx.length).toBeGreaterThan(0);
expect(newTx.length).toBeGreaterThan(0);
for (const arg of [...legacyTx, ...newTx]) expect(arg).toBeUndefined();
expect(newTx.length).toBe(0); // routed to #legacy only; the non-owning store is never touched
for (const arg of legacyTx) expect(arg).toBeUndefined();
}
);
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { RoutingRunStore } from "./runOpsStore.js";
import type { RunStore } from "./types.js";
// Env-scoped writes with no owning run (waitpoint tags; idempotency-key reset by predicate) must
// route to NEW when the env mints run-ops ids, instead of defaulting to LEGACY / fanning a wrong-DB
// write. Pure routing: fake RunStore slots record which store the router dispatches to.
type Call = { method: string; args: unknown[] };
type FakeStore = RunStore & { slot: "new" | "legacy"; calls: Call[] };
// `clearCount` lets a test say "this store matched N rows for the reset", so the NEW-first-then-fallback
// path can be exercised (NEW matches 0 → fall back to LEGACY).
function fakeStore(slot: "new" | "legacy", clearCount = slot === "new" ? 1 : 0): FakeStore {
const calls: Call[] = [];
const rec =
(method: string, result: unknown) =>
(...args: unknown[]) => {
calls.push({ method, args });
return Promise.resolve(result);
};
return {
slot,
calls,
upsertWaitpointTag: rec("upsertWaitpointTag", { id: slot, slot }),
clearIdempotencyKey: rec("clearIdempotencyKey", { count: clearCount }),
} as unknown as FakeStore;
}
function buildRouter(newClearCount?: number, legacyClearCount?: number) {
const newStore = fakeStore("new", newClearCount);
const legacyStore = fakeStore("legacy", legacyClearCount);
const router = new RoutingRunStore({
new: newStore,
legacy: legacyStore,
classify: (id: string) => (id.startsWith("new") ? "NEW" : "LEGACY"),
});
return { router, newStore, legacyStore };
}
describe("RoutingRunStore.upsertWaitpointTag — residency hint for a tag with no minted id", () => {
it("routes to NEW when residency is NEW and no id is supplied", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.upsertWaitpointTag(
{ environmentId: "env", name: "t", projectId: "p" },
undefined,
"NEW"
);
expect(newStore.calls.map((c) => c.method)).toEqual(["upsertWaitpointTag"]);
expect(legacyStore.calls).toHaveLength(0);
});
it("still falls back to LEGACY when no id and no residency are supplied", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.upsertWaitpointTag({ environmentId: "env", name: "t", projectId: "p" });
expect(legacyStore.calls.map((c) => c.method)).toEqual(["upsertWaitpointTag"]);
expect(newStore.calls).toHaveLength(0);
});
});
describe("RoutingRunStore.clearIdempotencyKey — predicate routes NEW-first when the env mints new", () => {
it("clears on NEW and does NOT touch legacy when NEW matches (post-flip key)", async () => {
const { router, newStore, legacyStore } = buildRouter(1, 0);
const result = await router.clearIdempotencyKey({
byPredicate: {
idempotencyKey: "k",
taskIdentifier: "task",
runtimeEnvironmentId: "env",
residency: "NEW",
},
});
expect(newStore.calls.map((c) => c.method)).toEqual(["clearIdempotencyKey"]);
expect(legacyStore.calls).toHaveLength(0);
expect(result.count).toBe(1);
});
it("falls back to LEGACY when NEW matches 0 (a key held on a pre-flip legacy run)", async () => {
// The env mints new now, but this key was created before the flip → its run lives on LEGACY.
const { router, newStore, legacyStore } = buildRouter(0, 1);
const result = await router.clearIdempotencyKey({
byPredicate: {
idempotencyKey: "k",
taskIdentifier: "task",
runtimeEnvironmentId: "env",
residency: "NEW",
},
});
// NEW checked first (0 rows), then LEGACY cleared the stale key — so the reset actually works.
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
expect(result.count).toBe(1);
});
it("still fans out a byPredicate reset with no residency (mixed residency)", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.clearIdempotencyKey({
byPredicate: { idempotencyKey: "k", taskIdentifier: "task", runtimeEnvironmentId: "env" },
});
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
});
it("routes byId to the owning store (unchanged)", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.clearIdempotencyKey({ byId: { runId: "new_run", idempotencyKey: "k" } });
expect(newStore.calls.map((c) => c.method)).toEqual(["clearIdempotencyKey"]);
expect(legacyStore.calls).toHaveLength(0);
});
});
@@ -834,18 +834,20 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
}
);
// ── Case 11b: deleteManyTaskRunWaitpoints by taskRunId fans out to both and sums (:944) ──
// A run's edges can straddle DBs mid-drain; a delete keyed by taskRunId (not waitpointId) must
// delete from BOTH DBs and sum the count.
// ── Case 11b: deleteManyTaskRunWaitpoints by taskRunId routes to the run's store (no fan-out) ──
// An edge always co-locates with its run (blockRunWithWaitpointEdges routes the write by runId), so
// a run's edges only ever live on ONE store — a straddle can't arise via the write path. A delete
// keyed by a classifiable taskRunId therefore routes to the run's store and must NOT touch the other
// DB. To prove the routing (not a fan-out), we manufacture an edge on the non-owning DB too and
// assert it survives the delete.
heteroRunOpsPostgresTest(
"case 11b: deleteManyTaskRunWaitpoints by taskRunId deletes edges on both DBs and sums",
"case 11b: deleteManyTaskRunWaitpoints by taskRunId routes to the run's store and leaves the other DB untouched",
async ({ prisma14, prisma17 }) => {
const { router } = makeSplitRouter(prisma14, prisma17);
const env = await seedSharedEnv(prisma14, "m11b");
// ONE logical run id whose edges happen to exist on BOTH DBs (the straddle the fan-out guards).
// The edge is FK-free on #new (unnest path) and FK-bound on #legacy, so seed a co-resident
// waitpoint + run on #legacy for its edge, and write the #new edge directly.
// A run-ops run (→ #new). Its real edge lives on #new; we also plant an edge on #legacy that the
// write path could never create, purely to prove the routed delete does not fan out to #legacy.
const runId = runOpsNew("m11br");
const legacyToken = cuidLegacy("m11bt");
await router.createRun(buildCreateRunInput({ runId, friendlyId: "run_m11b", ...env }));
@@ -881,7 +883,7 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
await prisma14.$executeRawUnsafe(
`INSERT INTO "TaskRunWaitpoint" ("id","taskRunId","waitpointId","projectId","createdAt","updatedAt") VALUES (gen_random_uuid(),'${runId}','${legacyToken}','${env.projectId}',NOW(),NOW())`
);
// #new edge (FK-free) pointing at a run-ops token absent locally — drain straddle.
// The run's real edge, on its own DB (#new, FK-free unnest path), pointing at a run-ops token.
const newToken = runOpsNew("m11bn");
await prisma17.$executeRawUnsafe(
`INSERT INTO "TaskRunWaitpoint" ("id","taskRunId","waitpointId","projectId","createdAt","updatedAt") VALUES (gen_random_uuid(),'${runId}','${newToken}','${env.projectId}',NOW(),NOW())`
@@ -891,10 +893,10 @@ describe("RoutingRunStore — mixed-residency matrix (cuid #legacy + run-ops id
expect(await prisma17.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(1);
const { count } = await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } });
expect(count).toBe(2); // one edge deleted on each DB, summed
expect(await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(0);
// Routed to #new (the run's store): only its edge is deleted; #legacy is never touched.
expect(count).toBe(1);
expect(await prisma17.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(0);
expect(await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } })).toBe(1);
}
);
});
@@ -0,0 +1,348 @@
import { describe, expect, it } from "vitest";
import { RoutingRunStore } from "./runOpsStore.js";
import type { ReadClient, RunStore } from "./types.js";
// Pure routing unit tests: run-keyed waitpoint/snapshot reads must route by the run id in scope
// instead of fanning out to BOTH run-ops DBs. No DB: each slot is a fake RunStore backed by a
// per-slot set of waitpoint rows / snapshot-join ids, so the assertions are purely about WHICH store
// the router queries (the co-located run's store, never the other) and about the route-then-fallback
// that keeps a rare cross-tree token visible. Correctness against real two-DB topology is covered by
// the heteroRunOpsPostgresTest suites (crossDbTokenBlock, snapshotCompletedWaitpoints, …).
type Call = { method: string; args: unknown[] };
type WaitpointRow = { id: string; status: "PENDING" | "COMPLETED" };
type FakeConfig = {
// Waitpoint rows resident on this store, keyed by id → status (for findManyWaitpoints /
// countPendingWaitpoints[WithPresence]).
waitpoints?: WaitpointRow[];
// Snapshot-join waitpoint ids resident on this store (for findSnapshotCompletedWaitpointIds).
snapshotWaitpointIds?: string[];
// Whether this store has the snapshot at all (for the WithPresence variant).
snapshotPresent?: boolean;
// Edge rows to return from findManyTaskRunWaitpoints, regardless of filter (routing-only).
edges?: Array<Record<string, unknown>>;
};
type FakeStore = RunStore & {
slot: "new" | "legacy";
calls: Call[];
primaryReadClient: { __primary: "new" | "legacy" };
};
function idsFromWhere(where: unknown): string[] | undefined {
const id = (where as { id?: unknown } | undefined)?.id;
if (typeof id === "string") return [id];
if (id && typeof id === "object") {
const inArr = (id as { in?: unknown }).in;
if (Array.isArray(inArr)) return inArr.filter((x): x is string => typeof x === "string");
}
return undefined;
}
function fakeStore(slot: "new" | "legacy", config: FakeConfig = {}): FakeStore {
const calls: Call[] = [];
const rows = config.waitpoints ?? [];
const byId = new Map(rows.map((r) => [r.id, r]));
const record = (method: string) => (args: unknown[]) => calls.push({ method, args });
const store: Partial<FakeStore> = {
slot,
calls,
primaryReadClient: { __primary: slot },
findManyTaskRunWaitpoints: ((args: unknown, client?: ReadClient) => {
record("findManyTaskRunWaitpoints")([args, client]);
return Promise.resolve((config.edges ?? []) as never);
}) as FakeStore["findManyTaskRunWaitpoints"],
deleteManyTaskRunWaitpoints: ((args: unknown, tx?: unknown) => {
record("deleteManyTaskRunWaitpoints")([args, tx]);
return Promise.resolve({ count: rows.length } as never);
}) as FakeStore["deleteManyTaskRunWaitpoints"],
findSnapshotCompletedWaitpointIds: ((snapshotId: string, client?: ReadClient) => {
record("findSnapshotCompletedWaitpointIds")([snapshotId, client]);
return Promise.resolve(config.snapshotWaitpointIds ?? []);
}) as FakeStore["findSnapshotCompletedWaitpointIds"],
findSnapshotCompletedWaitpointIdsWithPresence: ((snapshotId: string, client?: ReadClient) => {
record("findSnapshotCompletedWaitpointIdsWithPresence")([snapshotId, client]);
return Promise.resolve({
present: config.snapshotPresent ?? false,
ids: config.snapshotWaitpointIds ?? [],
});
}) as FakeStore["findSnapshotCompletedWaitpointIdsWithPresence"],
findManyWaitpoints: ((args: { where?: unknown }, client?: ReadClient) => {
record("findManyWaitpoints")([args, client]);
const requested = idsFromWhere(args.where);
const result =
requested === undefined
? rows
: requested.map((id) => byId.get(id)).filter((r): r is WaitpointRow => r != null);
return Promise.resolve(result as never);
}) as FakeStore["findManyWaitpoints"],
countPendingWaitpoints: ((waitpointIds: string[], client?: ReadClient) => {
record("countPendingWaitpoints")([waitpointIds, client]);
const count = waitpointIds.filter((id) => byId.get(id)?.status === "PENDING").length;
return Promise.resolve(count);
}) as FakeStore["countPendingWaitpoints"],
countPendingWaitpointsWithPresence: ((waitpointIds: string[], client?: ReadClient) => {
record("countPendingWaitpointsWithPresence")([waitpointIds, client]);
const presentIds = waitpointIds.filter((id) => byId.has(id));
const pendingIds = presentIds.filter((id) => byId.get(id)?.status === "PENDING");
return Promise.resolve({ pendingIds, presentIds });
}) as FakeStore["countPendingWaitpointsWithPresence"],
};
return store as unknown as FakeStore;
}
// Deterministic residency by id prefix via the classify seam (no dependence on id-shape rules).
function buildRouter(newConfig: FakeConfig = {}, legacyConfig: FakeConfig = {}) {
const newStore = fakeStore("new", newConfig);
const legacyStore = fakeStore("legacy", legacyConfig);
const router = new RoutingRunStore({
new: newStore,
legacy: legacyStore,
classify: (id: string) => (id.startsWith("new") ? "NEW" : "LEGACY"),
});
return { router, newStore, legacyStore };
}
const WRITER = { __writer: true } as unknown as ReadClient; // non-replica → escalates to own primary
describe("RoutingRunStore.findManyTaskRunWaitpoints — route by taskRunId (no fan-out)", () => {
it("routes an edge read keyed by a NEW run id to the new store only", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.findManyTaskRunWaitpoints({
where: { taskRunId: "new_run" },
select: { taskRunId: true },
});
expect(newStore.calls.map((c) => c.method)).toEqual(["findManyTaskRunWaitpoints"]);
expect(legacyStore.calls).toHaveLength(0);
});
it("routes an edge read keyed by a LEGACY run id to the legacy store only", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.findManyTaskRunWaitpoints({
where: { taskRunId: "legacy_run" },
select: { taskRunId: true },
});
expect(legacyStore.calls.map((c) => c.method)).toEqual(["findManyTaskRunWaitpoints"]);
expect(newStore.calls).toHaveLength(0);
});
it("escalates a caller writer client to the owning store's own primary", async () => {
const { router, newStore } = buildRouter();
await router.findManyTaskRunWaitpoints(
{ where: { taskRunId: "new_run" }, select: { taskRunId: true } },
WRITER
);
expect(newStore.calls[0]?.args[1]).toEqual({ __primary: "new" });
});
it("still fans out when keyed by waitpointId (no run id in scope)", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.findManyTaskRunWaitpoints({
where: { waitpointId: "waitpoint_x" },
select: { taskRunId: true },
});
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
});
});
describe("RoutingRunStore.deleteManyTaskRunWaitpoints — route by taskRunId (no fan-out)", () => {
it("deletes only on the owning store for a classifiable taskRunId", async () => {
const { router, newStore, legacyStore } = buildRouter({ waitpoints: [] });
const result = await router.deleteManyTaskRunWaitpoints({
where: { taskRunId: "legacy_run", id: { in: ["waitpoint_a"] } },
});
expect(legacyStore.calls.map((c) => c.method)).toEqual(["deleteManyTaskRunWaitpoints"]);
expect(newStore.calls).toHaveLength(0);
expect(result.count).toBe(0);
});
it("still fans out and sums when there is no taskRunId in the where", async () => {
const { router, newStore, legacyStore } = buildRouter();
await router.deleteManyTaskRunWaitpoints({ where: { waitpointId: "waitpoint_x" } });
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
});
it("never threads a caller tx into the routed delete", async () => {
const { router, legacyStore } = buildRouter();
await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: "legacy_run" } }, {
$fake: "cp-tx",
} as never);
expect(legacyStore.calls[0]?.args[1]).toBeUndefined();
});
});
describe("RoutingRunStore.findSnapshotCompletedWaitpointIds — route by runId", () => {
it("routes to the run's store when a runId is threaded through", async () => {
const { router, newStore, legacyStore } = buildRouter(
{ snapshotWaitpointIds: ["waitpoint_n"] },
{ snapshotWaitpointIds: ["waitpoint_l"] }
);
const ids = await router.findSnapshotCompletedWaitpointIds(
"c".repeat(25),
undefined,
"new_run"
);
expect(ids).toEqual(["waitpoint_n"]);
expect(legacyStore.calls).toHaveLength(0);
expect(newStore.calls.map((c) => c.method)).toEqual(["findSnapshotCompletedWaitpointIds"]);
});
it("still fans out and merges when no runId is supplied", async () => {
const { router, newStore, legacyStore } = buildRouter(
{ snapshotWaitpointIds: ["waitpoint_n"] },
{ snapshotWaitpointIds: ["waitpoint_l"] }
);
const ids = await router.findSnapshotCompletedWaitpointIds("c".repeat(25));
expect(ids.sort()).toEqual(["waitpoint_l", "waitpoint_n"]);
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
});
});
describe("RoutingRunStore.findSnapshotCompletedWaitpointIdsWithPresence — route by runId", () => {
it("routes to the run's store when a runId is threaded through", async () => {
const { router, newStore } = buildRouter(
{ snapshotWaitpointIds: ["waitpoint_n"], snapshotPresent: true },
{ snapshotWaitpointIds: ["waitpoint_l"], snapshotPresent: true }
);
const res = await router.findSnapshotCompletedWaitpointIdsWithPresence(
"c".repeat(25),
undefined,
"legacy_run"
);
expect(res).toEqual({ present: true, ids: ["waitpoint_l"] });
expect(newStore.calls).toHaveLength(0);
});
it("still fans out (present is the OR) when no runId is supplied", async () => {
const { router } = buildRouter(
{ snapshotWaitpointIds: [], snapshotPresent: false },
{ snapshotWaitpointIds: ["waitpoint_l"], snapshotPresent: true }
);
const res = await router.findSnapshotCompletedWaitpointIdsWithPresence("c".repeat(25));
expect(res).toEqual({ present: true, ids: ["waitpoint_l"] });
});
});
describe("RoutingRunStore.findManyWaitpoints — route by runId then fall back for missing ids", () => {
it("queries only the run's store when every requested token co-locates with the run", async () => {
const { router, newStore, legacyStore } = buildRouter({
waitpoints: [
{ id: "waitpoint_a", status: "COMPLETED" },
{ id: "waitpoint_b", status: "COMPLETED" },
],
});
const rows = (await router.findManyWaitpoints(
{ where: { id: { in: ["waitpoint_a", "waitpoint_b"] } } },
undefined,
"new_run"
)) as WaitpointRow[];
expect(rows.map((r) => r.id).sort()).toEqual(["waitpoint_a", "waitpoint_b"]);
expect(legacyStore.calls).toHaveLength(0);
expect(newStore.calls).toHaveLength(1);
});
it("falls back to the other store for ONLY the ids missing on the run's store (cross-tree token)", async () => {
const { router, legacyStore } = buildRouter(
{ waitpoints: [{ id: "waitpoint_local", status: "COMPLETED" }] },
{ waitpoints: [{ id: "waitpoint_crosstree", status: "COMPLETED" }] }
);
const rows = (await router.findManyWaitpoints(
{ where: { id: { in: ["waitpoint_local", "waitpoint_crosstree"] } } },
undefined,
"new_run"
)) as WaitpointRow[];
expect(rows.map((r) => r.id).sort()).toEqual(["waitpoint_crosstree", "waitpoint_local"]);
// The fallback leg is queried with ONLY the missing id, never the whole set.
const fallbackCall = legacyStore.calls[0];
expect(fallbackCall?.method).toBe("findManyWaitpoints");
const fallbackWhere = (fallbackCall!.args[0] as { where?: unknown }).where;
expect(idsFromWhere(fallbackWhere)).toEqual(["waitpoint_crosstree"]);
});
it("still fans out (NEW-wins dedup) when no runId is supplied", async () => {
const { router, newStore, legacyStore } = buildRouter(
{ waitpoints: [{ id: "waitpoint_a", status: "COMPLETED" }] },
{ waitpoints: [{ id: "waitpoint_a", status: "PENDING" }] }
);
const rows = (await router.findManyWaitpoints({
where: { id: { in: ["waitpoint_a"] } },
})) as WaitpointRow[];
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
// NEW-wins: the deduped row is the NEW copy (COMPLETED), not the stale legacy PENDING one.
expect(rows).toEqual([{ id: "waitpoint_a", status: "COMPLETED" }]);
});
});
describe("RoutingRunStore.countPendingWaitpoints — route by runId then partition-fallback", () => {
it("counts on the run's store only when every waitpoint co-locates with the run", async () => {
const { router, newStore, legacyStore } = buildRouter({
waitpoints: [
{ id: "waitpoint_a", status: "PENDING" },
{ id: "waitpoint_b", status: "COMPLETED" },
],
});
const count = await router.countPendingWaitpoints(
["waitpoint_a", "waitpoint_b"],
undefined,
"new_run"
);
expect(count).toBe(1);
expect(legacyStore.calls).toHaveLength(0);
expect(newStore.calls.map((c) => c.method)).toEqual(["countPendingWaitpointsWithPresence"]);
});
it("counts a cross-tree pending token via the fallback so a blocked run is not prematurely unblocked", async () => {
// The classic crossDbTokenBlock shape: a LEGACY run blocks on a token resident on the NEW DB.
const { router, newStore } = buildRouter(
{ waitpoints: [{ id: "waitpoint_crosstree", status: "PENDING" }] },
{ waitpoints: [] }
);
const count = await router.countPendingWaitpoints(
["waitpoint_crosstree"],
undefined,
"legacy_run"
);
expect(count).toBe(1);
// Fallback queried the other store with ONLY the id missing on the run's store.
expect(newStore.calls.map((c) => c.method)).toEqual(["countPendingWaitpoints"]);
expect(newStore.calls[0]?.args[0]).toEqual(["waitpoint_crosstree"]);
});
it("trusts the run's store for an id present there (COMPLETED) even if a stale mirror is PENDING elsewhere", async () => {
const { router, legacyStore } = buildRouter(
{ waitpoints: [{ id: "waitpoint_a", status: "COMPLETED" }] },
{ waitpoints: [{ id: "waitpoint_a", status: "PENDING" }] }
);
const count = await router.countPendingWaitpoints(["waitpoint_a"], undefined, "new_run");
// Present on the run's store → not in the missing set → the other store is never consulted.
expect(count).toBe(0);
expect(legacyStore.calls).toHaveLength(0);
});
it("still fans out and sums when no runId is supplied", async () => {
const { router, newStore, legacyStore } = buildRouter(
{ waitpoints: [{ id: "waitpoint_a", status: "PENDING" }] },
{ waitpoints: [{ id: "waitpoint_b", status: "PENDING" }] }
);
const count = await router.countPendingWaitpoints(["waitpoint_a", "waitpoint_b"]);
expect(count).toBe(2);
expect(newStore.calls).toHaveLength(1);
expect(legacyStore.calls).toHaveLength(1);
});
});
@@ -0,0 +1,148 @@
// A STANDALONE waitpoint/token (wait.createToken with no owning run) has an always-cuid id, so
// id-shape routing sends it to LEGACY. In a fully-minted-new deployment (env mints run-ops ids,
// legacy draining) that strands a new run's token on the draining DB and, under a read-only legacy
// connection, fails the write outright. A standalone token must instead read the env mint kind and
// land on the run's DB (NEW) via the `residency` co-location hint. Real two-DB topology; never mocked.
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import { expect } from "vitest";
import { PostgresRunStore } from "./PostgresRunStore.js";
import { RoutingRunStore } from "./runOpsStore.js";
const CUID_WAITPOINT = "c".repeat(25); // cuid id → classifies LEGACY (the always-wrong key for a new token)
function makeRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
const newStore = new PostgresRunStore({
prisma: prisma17 as never,
readOnlyPrisma: prisma17 as never,
schemaVariant: "dedicated",
});
const legacyStore = new PostgresRunStore({
prisma: prisma14,
readOnlyPrisma: prisma14,
schemaVariant: "legacy",
});
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
}
// Seed real org/project/env on #legacy so its FKs are satisfied whichever store the waitpoint lands on
// (the #new dedicated subset is FK-free). Returns the ids the waitpoint rows reference.
async function seedLegacyEnv(prisma: PrismaClient, suffix: string) {
const organization = await prisma.organization.create({
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${suffix}`,
slug: `project-${suffix}`,
externalRef: `proj_${suffix}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `prod-${suffix}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${suffix}`,
pkApiKey: `pk_prod_${suffix}`,
shortcode: `short_${suffix}`,
maximumConcurrencyLimit: 10,
},
});
return { projectId: project.id, environmentId: environment.id };
}
describe("run-ops split — a standalone token pins to NEW via the residency hint (not legacy by id-shape)", () => {
heteroRunOpsPostgresTest(
"createWaitpoint with residency NEW lands a cuid token on #new, absent from #legacy",
async ({ prisma14, prisma17 }: { prisma14: PrismaClient; prisma17: RunOpsPrismaClient }) => {
const router = makeRouter(prisma14, prisma17);
const { projectId, environmentId } = await seedLegacyEnv(prisma14, "swr_create");
await router.createWaitpoint(
{
data: {
id: CUID_WAITPOINT,
friendlyId: "wp_standalone",
type: "MANUAL",
status: "PENDING",
idempotencyKey: `idem_${CUID_WAITPOINT}`,
userProvidedIdempotencyKey: false,
projectId,
environmentId,
},
},
undefined,
{ residency: "NEW" }
);
// GREEN: the residency hint routes the cuid token to #new. RED: id-shape sends it to #legacy.
expect(await prisma17.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(1);
expect(await prisma14.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(0);
}
);
heteroRunOpsPostgresTest(
"upsertWaitpoint with residency NEW lands a cuid token on #new, absent from #legacy",
async ({ prisma14, prisma17 }: { prisma14: PrismaClient; prisma17: RunOpsPrismaClient }) => {
const router = makeRouter(prisma14, prisma17);
const { projectId, environmentId } = await seedLegacyEnv(prisma14, "swr_upsert");
await router.upsertWaitpoint(
{
where: { id: CUID_WAITPOINT },
create: {
id: CUID_WAITPOINT,
friendlyId: "wp_standalone_upsert",
type: "MANUAL",
status: "PENDING",
idempotencyKey: `idem_${CUID_WAITPOINT}`,
userProvidedIdempotencyKey: false,
projectId,
environmentId,
},
update: {},
},
undefined,
{ residency: "NEW" }
);
expect(await prisma17.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(1);
expect(await prisma14.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(0);
}
);
heteroRunOpsPostgresTest(
"coLocateWithRunId wins over residency (a co-located waitpoint inherits its run, ignores the flag)",
async ({ prisma14, prisma17 }: { prisma14: PrismaClient; prisma17: RunOpsPrismaClient }) => {
const router = makeRouter(prisma14, prisma17);
const { projectId, environmentId } = await seedLegacyEnv(prisma14, "swr_colocate");
const legacyRunId = `run_${"c".repeat(25)}`; // cuid run → #legacy
await router.createWaitpoint(
{
data: {
id: CUID_WAITPOINT,
friendlyId: "wp_colocated",
type: "DATETIME",
status: "PENDING",
idempotencyKey: `idem_${CUID_WAITPOINT}`,
userProvidedIdempotencyKey: false,
projectId,
environmentId,
},
},
undefined,
{ coLocateWithRunId: legacyRunId, residency: "NEW" }
);
// Co-location must win → the waitpoint lands on the run's store (#legacy), NOT the residency hint.
expect(await prisma14.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(1);
expect(await prisma17.waitpoint.count({ where: { id: CUID_WAITPOINT } })).toBe(0);
}
);
});

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