Merge branch 'main' into fix/waitpoint-completion-db-error-mislabel
This commit is contained in:
@@ -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.
|
||||
@@ -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 1–2 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-2
@@ -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
@@ -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 @@
|
||||
v22.23.1
|
||||
v24.18.0
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()})()` }} />
|
||||
);
|
||||
}
|
||||
@@ -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'");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
@@ -303,6 +303,7 @@ export default function Page() {
|
||||
<RuntimeIcon
|
||||
runtime={deployment.runtime}
|
||||
runtimeVersion={deployment.runtimeVersion}
|
||||
withLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
|
||||
+53
-35
@@ -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>();
|
||||
|
||||
+1
-1
@@ -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, {
|
||||
|
||||
+1
-1
@@ -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, {
|
||||
|
||||
+98
-75
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(", ")}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user