Skip to content

Commit 681f566

Browse files
committed
fix(webapp): preserve hosted webhook endpoint status across deploys
A redeploy no longer re-activates a hosted webhook endpoint that was disabled via the API: the declarative sync only marks an endpoint active when it first creates it, so an operator disable survives future deploys. A deploy that omits the webhook list entirely (an older client) also no longer deactivates existing endpoints, which is now distinguished from an explicit empty list.
1 parent 55369c5 commit 681f566

4 files changed

Lines changed: 196 additions & 5 deletions

File tree

apps/webapp/app/v3/services/changeCurrentDeployment.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export class ChangeCurrentDeploymentService extends BaseService {
219219

220220
await syncDeclarativeSchedules(parsed.data.tasks, worker, environment, this._prisma);
221221
await syncDeclarativeWebhooks(
222-
parsed.data.webhooks ?? [],
222+
parsed.data.webhooks,
223223
worker,
224224
environment,
225225
this._prisma,

apps/webapp/app/v3/services/createBackgroundWorker.server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export class CreateBackgroundWorkerService extends BaseService {
209209

210210
const [webhooksError] = await tryCatch(
211211
syncDeclarativeWebhooks(
212-
body.metadata.webhooks ?? [],
212+
body.metadata.webhooks,
213213
backgroundWorker,
214214
environment,
215215
this._prisma,
@@ -673,13 +673,15 @@ function generateOpaqueId(): string {
673673
}
674674

675675
export async function syncDeclarativeWebhooks(
676-
webhooks: WebhookResource[],
676+
webhooks: WebhookResource[] | undefined,
677677
worker: BackgroundWorker,
678678
environment: AuthenticatedEnvironment,
679679
prisma: PrismaClientOrTransaction,
680680
// Endpoint rows live on the webhook DB; the task-existence check below stays on the main client.
681681
webhookPrisma: WebhookDatabase
682682
) {
683+
if (webhooks === undefined) return;
684+
683685
const existing = await webhookPrisma.webhookEndpoint.findMany({
684686
where: {
685687
runtimeEnvironmentId: environment.id,
@@ -751,7 +753,6 @@ export async function syncDeclarativeWebhooks(
751753
verifierArtifact: wh.verifierArtifact as unknown as Prisma.InputJsonValue,
752754
secretProvisioning: wh.secretProvisioning ?? "either",
753755
metadata: (wh.metadata ?? {}) as unknown as Prisma.InputJsonValue,
754-
status: "ACTIVE",
755756
...filterData,
756757
},
757758
});

apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
232232

233233
const [webhooksError] = await tryCatch(
234234
syncDeclarativeWebhooks(
235-
body.metadata.webhooks ?? [],
235+
body.metadata.webhooks,
236236
backgroundWorker,
237237
environment,
238238
this._prisma,
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { containerTest } from "@internal/testcontainers";
2+
import type { WebhookResource } from "@trigger.dev/core/v3";
3+
import type { BackgroundWorker, PrismaClient } from "@trigger.dev/database";
4+
import { describe, expect, vi } from "vitest";
5+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
6+
import { syncDeclarativeWebhooks } from "~/v3/services/createBackgroundWorker.server";
7+
8+
vi.setConfig({ testTimeout: 60_000 });
9+
10+
type WorkerArg = Parameters<typeof syncDeclarativeWebhooks>[1];
11+
const noWorker = {} as unknown as WorkerArg;
12+
13+
async function seedProjectWithEnv(prisma: PrismaClient) {
14+
const slug = `sdw_${Math.random().toString(36).slice(2, 10)}`;
15+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
16+
const project = await prisma.project.create({
17+
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
18+
});
19+
const environment = await prisma.runtimeEnvironment.create({
20+
data: {
21+
slug: "prod",
22+
type: "PRODUCTION",
23+
projectId: project.id,
24+
organizationId: organization.id,
25+
apiKey: `tr_prod_${slug}`,
26+
pkApiKey: `pk_prod_${slug}`,
27+
shortcode: `p${slug.slice(0, 5)}`,
28+
},
29+
});
30+
return { organization, project, environment };
31+
}
32+
33+
async function seedWorkerWithTask(
34+
prisma: PrismaClient,
35+
project: { id: string },
36+
environment: { id: string },
37+
taskSlug: string
38+
): Promise<BackgroundWorker> {
39+
const suffix = Math.random().toString(36).slice(2, 10);
40+
const worker = await prisma.backgroundWorker.create({
41+
data: {
42+
friendlyId: `worker_${suffix}`,
43+
contentHash: `hash_${suffix}`,
44+
version: "20260101.1",
45+
metadata: {},
46+
projectId: project.id,
47+
runtimeEnvironmentId: environment.id,
48+
},
49+
});
50+
await prisma.backgroundWorkerTask.create({
51+
data: {
52+
friendlyId: `task_${suffix}`,
53+
slug: taskSlug,
54+
filePath: `src/trigger/${taskSlug}.ts`,
55+
workerId: worker.id,
56+
projectId: project.id,
57+
runtimeEnvironmentId: environment.id,
58+
},
59+
});
60+
return worker;
61+
}
62+
63+
async function seedEndpoint(
64+
prisma: PrismaClient,
65+
base: { organizationId: string; projectId: string; runtimeEnvironmentId: string },
66+
handlerWebhookId: string,
67+
status: "ACTIVE" | "INACTIVE"
68+
) {
69+
const suffix = Math.random().toString(36).slice(2, 10);
70+
return prisma.webhookEndpoint.create({
71+
data: {
72+
friendlyId: `wh_${suffix}`,
73+
opaqueId: `op_${suffix}${Math.random().toString(36).slice(2, 10)}`,
74+
organizationId: base.organizationId,
75+
projectId: base.projectId,
76+
runtimeEnvironmentId: base.runtimeEnvironmentId,
77+
environmentType: "PRODUCTION",
78+
source: "stripe",
79+
handlerWebhookId,
80+
routingTarget: { type: "task", taskId: "handle-stripe" },
81+
verifierArtifact: { kind: "bundle", bundleUrl: "https://example.test/v.js", hash: "h" },
82+
status,
83+
},
84+
});
85+
}
86+
87+
function makeWebhookResource(id: string, taskId: string): WebhookResource {
88+
return {
89+
id,
90+
filePath: `src/trigger/${id}.ts`,
91+
source: "stripe",
92+
verifierArtifact: { kind: "bundle", bundleUrl: "https://example.test/v.js", hash: "h" },
93+
routingTarget: { type: "task", taskId },
94+
};
95+
}
96+
97+
const asEnv = (env: unknown) => env as AuthenticatedEnvironment;
98+
99+
describe("syncDeclarativeWebhooks status reconciliation", () => {
100+
containerTest(
101+
"an absent webhooks list (older client) does not deactivate existing endpoints",
102+
async ({ prisma }) => {
103+
const { organization, project, environment } = await seedProjectWithEnv(prisma);
104+
const endpoint = await seedEndpoint(
105+
prisma,
106+
{
107+
organizationId: organization.id,
108+
projectId: project.id,
109+
runtimeEnvironmentId: environment.id,
110+
},
111+
"declared-webhook",
112+
"ACTIVE"
113+
);
114+
115+
await syncDeclarativeWebhooks(undefined, noWorker, asEnv(environment), prisma, prisma);
116+
117+
const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } });
118+
expect(after.status).toBe("ACTIVE");
119+
}
120+
);
121+
122+
containerTest(
123+
"an explicit empty list deactivates endpoints that are no longer declared",
124+
async ({ prisma }) => {
125+
const { organization, project, environment } = await seedProjectWithEnv(prisma);
126+
const endpoint = await seedEndpoint(
127+
prisma,
128+
{
129+
organizationId: organization.id,
130+
projectId: project.id,
131+
runtimeEnvironmentId: environment.id,
132+
},
133+
"declared-webhook",
134+
"ACTIVE"
135+
);
136+
137+
await syncDeclarativeWebhooks([], noWorker, asEnv(environment), prisma, prisma);
138+
139+
const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } });
140+
expect(after.status).toBe("INACTIVE");
141+
}
142+
);
143+
144+
containerTest(
145+
"a redeploy does not re-activate an endpoint disabled via the API",
146+
async ({ prisma }) => {
147+
const { organization, project, environment } = await seedProjectWithEnv(prisma);
148+
const worker = await seedWorkerWithTask(prisma, project, environment, "handle-stripe");
149+
const endpoint = await seedEndpoint(
150+
prisma,
151+
{
152+
organizationId: organization.id,
153+
projectId: project.id,
154+
runtimeEnvironmentId: environment.id,
155+
},
156+
"declared-webhook",
157+
"INACTIVE"
158+
);
159+
160+
await syncDeclarativeWebhooks(
161+
[makeWebhookResource("declared-webhook", "handle-stripe")],
162+
worker,
163+
asEnv(environment),
164+
prisma,
165+
prisma
166+
);
167+
168+
const after = await prisma.webhookEndpoint.findUniqueOrThrow({ where: { id: endpoint.id } });
169+
expect(after.status).toBe("INACTIVE");
170+
}
171+
);
172+
173+
containerTest("a newly declared webhook creates an active endpoint", async ({ prisma }) => {
174+
const { project, environment } = await seedProjectWithEnv(prisma);
175+
const worker = await seedWorkerWithTask(prisma, project, environment, "handle-stripe");
176+
177+
await syncDeclarativeWebhooks(
178+
[makeWebhookResource("brand-new-webhook", "handle-stripe")],
179+
worker,
180+
asEnv(environment),
181+
prisma,
182+
prisma
183+
);
184+
185+
const created = await prisma.webhookEndpoint.findFirst({
186+
where: { runtimeEnvironmentId: environment.id, handlerWebhookId: "brand-new-webhook" },
187+
});
188+
expect(created?.status).toBe("ACTIVE");
189+
});
190+
});

0 commit comments

Comments
 (0)