# Tâches planifiées

Une tâche planifiée exécute du travail à heure fixe, sans visiteur : purger des sessions, envoyer un récapitulatif, recalculer un classement. Bastide en offre deux sortes : les tâches de l'application, déclarées dans `glowsoft.json`, et les tâches en base, écrites en SQL à la manière de `pg_cron`. Cette page décrit les deux, leur syntaxe, leur fuseau, leurs limites par palier et la lecture de leurs exécutions.

## Les tâches de l'application

Elles se déclarent dans le `glowsoft.json` à la racine de votre dépôt, et prennent effet au déploiement suivant. Elles ne s'exécutent que pour la **production**.

### Appeler un chemin

La forme est celle de `vercel.json` : un projet importé de Vercel garde ses tâches.

```json
{
  "crons": [
    { "path": "/api/cron/nettoyage", "schedule": "0 3 * * *" },
    { "path": "/api/cron/rapport?format=court", "schedule": "30 7 * * MON", "timezone": "Europe/Paris", "overlap": "skip", "timeoutSeconds": 120, "retries": 1 }
  ]
}
```

À chaque échéance, la plateforme appelle `GET <path>` sous le nom d'hôte de la production, en passant par l'edge comme un visiteur : une application endormie se réveille pour sa tâche. L'appel porte :

- `User-Agent: bastide-cron/1` ;
- `X-Bastide-Execution` : l'identifiant unique de la tentative (`exp_…`) ;
- `X-Bastide-Signature` : une signature Ed25519, `t=<secondes Unix>,kid=<empreinte>,v1=<signature en base64url>`.

Une réponse 2xx réussit la tentative ; toute autre l'échoue, et elle se réessaie. Une application qu'on ne joint pas (aucune connexion, ou un 502 de la plateforme) fait dire l'échéance **manquée** après sa dernière tentative.

### Vérifier que l'appel vient de la plateforme

`User-Agent` et `X-Bastide-Execution` ne prouvent rien : vérifiez la signature. Les octets signés sont six lignes jointes par `\n`, sans ligne finale : `bastide-cron/1`, `GET`, l'en-tête `Host` reçu en minuscules, le chemin reçu (chaîne de requête comprise), la valeur de `X-Bastide-Execution`, et `t`.

1. Lisez la clé publique une fois : `GET https://api.bastide.app/v1/scheduled-runs/signing-key` rend `{ algorithm, keyId, publicKey }`, la clé en PEM. Aucun jeton n'est demandé.
2. Refusez un `kid` qui n'est pas le `keyId` lu : la clé a été remplacée, relisez-la.
3. Refusez un `t` à plus de 300 secondes de votre horloge.
4. Vérifiez la signature sur ce que vous avez **reçu**. Pour refuser un rejeu, retenez `X-Bastide-Execution` : il ne revient jamais.

```js
import { createPublicKey, verify } from "node:crypto";

const cle = await (await fetch("https://api.bastide.app/v1/scheduled-runs/signing-key")).json();
const publique = createPublicKey(cle.publicKey);

export function vientDeLaPlateforme(req) {
  const m = /^t=(\d+),kid=([0-9a-f]{16}),v1=([A-Za-z0-9_-]{86})$/.exec(
    req.headers["x-bastide-signature"] ?? "",
  );
  if (!m || m[2] !== cle.keyId) return false;
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const signe = [
    "bastide-cron/1",
    "GET",
    String(req.headers.host).toLowerCase(),
    req.url,
    req.headers["x-bastide-execution"],
    m[1],
  ].join("\n");
  return verify(null, Buffer.from(signe), publique, Buffer.from(m[3], "base64url"));
}
```

### Lancer un conteneur : le service `cron`

Une tâche peut aussi tourner dans son propre conteneur, sans passer par votre service `web` :

```json
{
  "services": [
    { "name": "web", "type": "web" },
    { "name": "nettoyage", "type": "cron", "start": "nettoyer", "schedule": "0 3 * * *" }
  ]
}
```

- `services` porte toujours exactement un `web`.
- `start` est le nom d'un script de votre `package.json`, jamais une commande. Le conteneur se construit comme un `worker` et démarre par ce script.
- Aucun appel ne part et rien n'est signé : le **code de sortie** conclut la tentative, zéro réussit, tout autre échoue et se réessaie. Passé son délai de garde, le conteneur est arrêté et la tentative échoue.

### Les champs d'une tâche

| Champ | Ce qu'il dit | Défaut |
| --- | --- | --- |
| `path` | Le chemin appelé, chaîne de requête comprise ; commence par `/`, 255 caractères au plus. | requis (tâche par chemin) |
| `schedule` | L'expression, 128 caractères au plus. | requis |
| `timezone` | Un nom de la base IANA. | `Europe/Paris` |
| `overlap` | Si l'exécution précédente tourne encore : `skip` écarte l'échéance, `queue` l'attend, `kill` arrête la précédente. | `skip` |
| `timeoutSeconds` | Le délai de garde d'une exécution, de 10 à 3 600 secondes. | 300 |
| `retries` | Les réessais d'une exécution en échec, de 0 à 5, à 30 secondes de recul doublé à chaque tentative. | 2 |

### Au moins une fois

Une tentative peut s'exécuter **plus d'une fois** : un réessai suit un échec même quand votre code avait fini son travail, et le conteneur d'un service `cron` dont le nœud est remplacé repart sur le suivant. Écrivez une tâche qui se rejoue sans dommage. Une échéance découverte avec plus de cinq minutes de retard n'est pas lancée : elle est inscrite manquée. Un déploiement neuf ne rattrape pas les échéances passées.

## La syntaxe des expressions

Cinq champs, à la minute : minute, heure, jour du mois, mois, jour de la semaine.

| Élément | Exemple | Sens |
| --- | --- | --- |
| Valeur | `30 7 * * *` | Chaque jour à 7 h 30. |
| Liste | `0 8,12,18 * * *` | À 8 h, 12 h et 18 h. |
| Intervalle | `0 9-17 * * MON-FRI` | Chaque heure de 9 h à 17 h, du lundi au vendredi. |
| Pas | `*/15 * * * *` | Toutes les quinze minutes. |
| Noms | `0 0 1 JAN *` | Les mois `JAN`…`DEC` et les jours `SUN`…`SAT`, sans casse. |
| Raccourcis | `@daily` | `@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`, `@annually`. |

- Le dimanche vaut `0` et `7`.
- **Quand le jour du mois ET le jour de la semaine sont restreints, la tâche passe si l'un OU l'autre convient** : `0 0 1 * MON` veut dire « le 1er et chaque lundi », comme chez cron et Vercel.
- **Les changements d'heure** suivent la règle de `cronie`. Au printemps, une tâche à heure fixe tombée dans l'heure qui n'existe pas passe une fois, à la première minute après le saut. À l'automne, elle passe à la première occurrence seulement. Une tâche dont le champ des heures est `*` ou `*/n` suit le temps réel.
- La granularité est la minute : une expression à la seconde est refusée.

Une expression ou un fuseau faux **écartent la tâche sans faire échouer le déploiement** : le journal du déploiement dit le rang de la tâche, le champ et la règle, jamais la valeur écrite.

## Les tâches en base

Pour du travail purement SQL, écrivez la tâche dans votre base, avec les fonctions que vous écririez chez Supabase :

```sql
select cron.schedule('purge-sessions', '*/10 * * * *', $$ delete from sessions where expire_le < now() $$);
select cron.unschedule('purge-sessions');
select * from cron.job_run_details order by start_time desc limit 20;
```

`cron.alter_job(job_id, active, fenetre_secondes)` suspend ou réactive une tâche et règle sa fenêtre. Ce n'est pas `pg_cron` : la plateforme lit vos tâches et les exécute elle-même, ce qui change plusieurs choses.

- **Le fuseau est UTC**, et la granularité la minute.
- **La commande s'exécute dans une transaction**, sous le rôle de votre projet, par le mutualiseur : ni `VACUUM` ni `COMMIT`. Elle est arrêtée au bout de 10 minutes.
- **50 tâches au plus par base.**
- **Une fenêtre** (de 60 à 3 600 secondes, 300 par défaut) : une échéance qui n'a pas démarré dans ce délai est inscrite `missed`, avec sa cause, au lieu de disparaître. Une échéance dont l'exécution précédente tourne encore l'est aussi.
- **Une tâche neuve** est vue au plus tard cinq minutes après sa création.
- **L'historique** (`cron.job_run_details`, états `running`, `succeeded`, `failed`, `missed`) est purgé au-delà de 30 jours.
- **Une alerte** : chaque échec ou échéance manquée émet l'événement `database.cron.failed` vers les points de réception du projet, groupé par tâche (voir [Webhooks](webhooks.md)).

## Les limites par palier

| Palier | Tâches de l'application par projet |
| --- | --- |
| Découverte | 5 |
| Standard | 20 |
| Pro | 100 |
| Souverain | 500 |

Les tâches par chemin (`crons`) et les services `cron` comptent ensemble. Toutes les tâches déclarées comptent, même celles qui seront écartées pour une expression fausse. Au-delà du plafond, le déploiement est refusé avec un message qui nomme le palier et le plafond. Les tâches en base ont leur propre plafond de 50 par base, quel que soit le palier.

## Lire les exécutions

Chaque tentative d'une tâche de l'application (son échéance, son état, sa durée, son code, et le début de la réponse ou de ce que le conteneur a écrit) est gardée **90 jours**.

- **Console** : l'écran **Journaux**, vue « Tâches planifiées ».
- **Agent** : `logs_read` avec `scheduledRuns: true` ; cinquante tentatives au plus par lecture, des plus récentes aux plus anciennes, le reste compté.
- **Ligne de commande** : `bastide logs read --project-id <projet> --scheduled-runs`.

```json
{
  "projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "environmentId": "env_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "scheduledRuns": true
}
```

Les exécutions des tâches en base se lisent dans `cron.job_run_details`, par l'éditeur SQL ou `database_sql_read`.

## Erreurs fréquentes

- **« N tâche(s) planifiée(s) écartée(s), le déploiement continue »** : lisez le rang et le champ ; corrigez l'expression ou le fuseau.
- **Déploiement refusé, « au-delà des N que le palier permet »** : retirez des tâches ou changez de palier.
- **Échéance manquée** : l'application n'a pas répondu (arrêtée, 502) ; lisez ses journaux.
- **La tâche tourne deux fois** : c'est un réessai ; rendez-la rejouable avec `X-Bastide-Execution`.
- **Une tâche en base ne part pas** : vérifiez qu'elle est active et que son expression a cinq champs ; une expression refusée n'est jamais exécutée, et le refus n'est signalé qu'une fois.

## Voir aussi

- [Journaux](logs.md), [Webhooks](webhooks.md), [Base de données](database.md), [Migrer depuis Vercel](from-vercel.md), [Limites par palier](limits.md)
- Référence : [logs.read](../reference/operations/logs.read.md)
