# Base de données booster

> Lire et écrire les enregistrements de la base de données d'un panneau Booster depuis votre propre serveur avec une clé API.

Un panneau Booster est une petite application web créée dans AYETO Booster Studio. Un panneau
peut avoir sa propre base de données, et les endpoints de cette page permettent à votre serveur
de lire et de modifier cette base de données avec une clé API : importer des données dans un panneau,
les synchroniser avec un autre système ou traiter ce que les utilisateurs du panneau ont saisi.

Tous les endpoints se trouvent sous `/api/v3/booster/database/private/`. Les panneaux eux-mêmes
accèdent à leur base de données par un autre chemin d'accès, qui n'est pas décrit ici.

## Concepts

### Les panneaux et leur base de données

Chaque panneau a au plus une base de données. Elle existe lorsque le paramètre **Accès à la base de données**
du panneau (détails du panneau dans l'application) vaut **Base de données publique** ou **Base de données privée** ;
avec **Pas de base de données**, tous les endpoints de cette page répondent `403`. Les modes public et
privé peuvent tous deux être utilisés via cette API. La base de données est créée à la première utilisation et
est supprimée avec le panneau.

Chaque requête désigne le panneau dans l'en-tête `panel-id`. L'ID du panneau est affiché, avec
un bouton de copie, en haut des détails du panneau dans l'application.

### Collections et enregistrements

Une base de données contient des **collections**, et une collection contient des **enregistrements**. Les collections
n'ont pas besoin d'être créées : une collection existe dès qu'elle contient un enregistrement. Les noms de collection
ne peuvent contenir que des lettres, des chiffres et des underscores (`orders`, `contact_requests`).

Un enregistrement a exactement deux champs :

| Champ | Type | Description |
|---|---|---|
| `key` | UUID | Identifiant de l'enregistrement, unique dans les bases de données de tous les panneaux (pas seulement dans sa collection). |
| `value` | tout type | Les données que vous avez stockées. Toute valeur JSON est acceptée ; utilisez un objet si vous voulez filtrer ou trier sur ses champs. |

Les enregistrements ne portent aucune autre métadonnée : les réponses n'incluent ni le nom de la collection,
ni les horodatages, ni l'auteur. Si vous en avez besoin, stockez-les dans `value`.

Les endpoints qui travaillent sur un seul enregistrement (`get`, `update`, `delete` et leurs variantes
par lot) l'adressent uniquement par sa `key` ; la collection est déduite de l'enregistrement stocké.
Les endpoints qui travaillent sur une collection entière (`create`, `find`, `count`, verrous) prennent
le nom de la collection dans le corps.

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

### Qui peut accéder aux données d'un panneau

Les requêtes agissent au nom de l'utilisateur propriétaire de la clé API. Cet utilisateur doit :

- être propriétaire du panneau (en être le créateur, ou membre du groupe qui le possède, par exemple
  les administrateurs d'une organisation qui a reçu le panneau d'un partenaire), ou
- avoir reçu le panneau en partage, directement ou via un groupe auquel il appartient.

Les propriétaires et les utilisateurs avec qui le panneau est partagé (partage en lecture ou en écriture) peuvent lire et écrire
la base de données, dans les deux modes : écrire dans la base de données compte comme une utilisation du
panneau, pas comme sa modification (modifier le panneau lui-même nécessite toujours un partage en écriture).
L'appartenance à l'organisation du panneau ne donne pas accès à elle seule, pas plus que les
comptes administrateur AYETO. La clé API doit également porter la
[portée](authentication.md#portees) correspondante :

| Portée | Endpoints |
|---|---|
| `ayeto.booster.database` | `get`, `find`, `count` |
| `ayeto.booster.database.write` | `create`, `create-many`, `update`, `update-many`, `delete`, `delete-many`, `lock/acquire`, `lock/release` |

La portée d'écriture n'inclut pas la portée de lecture ; une clé qui lit et écrit a besoin
des deux.

Les écritures effectuées via cette API sont des écritures dans la base de données du panneau comme les autres : elles
démarrent les workflows ayant un déclencheur d'enregistrement booster sur le panneau et exécutent les modules de
webhook et de notification par e-mail du panneau.

### Règles de collection

Un panneau peut définir des règles de collection qui déterminent ce que les visiteurs anonymes du panneau
peuvent faire. Les règles ne s'appliquent qu'en mode **base de données publique** ; un panneau en mode privé les
ignore, et tous les endpoints de cette page fonctionnent sur toutes ses collections.

Pour un panneau en mode public, une seule partie des règles compte pour les requêtes de cette
page :

- Une opération réglée sur **none** pour une collection (ou pour la règle `*` qui la couvre)
  est désactivée pour tout le monde, y compris pour cette API. La requête échoue avec `403`.
- Tous les autres niveaux (public, secret, private) autorisent les requêtes API.
- Une collection sans règle correspondante est entièrement accessible à l'API.

Les contraintes d'écriture d'une règle (schéma de validation, taille maximale de la valeur, nombre
maximal d'enregistrements) ne sont **pas** appliquées aux écritures effectuées via cette API. Validez les
données de votre côté avant de les écrire.

### Limites

| Limite | Valeur |
|---|---|
| Enregistrements renvoyés par un `find` | jusqu'à 1000 avec pagination (`limit_to` vaut au plus `1000`, `limit_from` est inférieur à `1000`) ; tous les enregistrements correspondants si aucune limite n'est envoyée |
| Conditions de filtre dans une requête | 1000, y compris les conditions à l'intérieur de `AND` / `OR` |
| Imbrication de `AND` / `OR` | 100 niveaux |
| Valeurs dans une condition `in` | 100 |
| Valeur de filtre de type chaîne | 1000 caractères |
| Durée de vie d'un verrou | plus de 0,1 s, 3600 s par défaut |

Les endpoints par lot n'ont pas de nombre maximal fixe d'éléments, mais chaque élément est écrit
séparément (voir [Créer plusieurs enregistrements](#creer-plusieurs-enregistrements)) ; limitez les lots à quelques
centaines d'éléments pour qu'une requête se termine largement dans le délai d'expiration HTTP.

## Interroger les enregistrements

`find` et `count` acceptent un objet `params` facultatif. Ses champs suivent les
[conventions de requête](conventions.md#interroger-des-listes) générales, avec les différences indiquées ici.

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `filters` | tableau | non | Conditions, qui doivent toutes être satisfaites. Voir [Filtres](conventions.md#filtres) et ci-dessous. |
| `sort_key` | chaîne | non | Champ de tri : `key` ou `value.<field>`. |
| `sort_order` | entier | non | `0` croissant (par défaut lorsque `sort_key` est défini), `1` décroissant. |
| `limit_from` | entier | non | Index du premier enregistrement à renvoyer, à partir de `0`, inférieur à `1000`. |
| `limit_to` | entier | non | Index qui suit le dernier enregistrement à renvoyer (pas un nombre d'éléments), au plus `1000`. Lorsque seul `limit_from` est envoyé, il vaut `limit_from + 100` (au plus `1000`). |

### Champs filtrables et triables

Seuls deux types de noms de champs sont acceptés :

- `key`, la clé de l'enregistrement (à comparer avec une chaîne UUID).
- `value.<path>`, un champ à l'intérieur de la valeur de l'enregistrement, avec des points pour les objets imbriqués :
  `value.status`, `value.customer.email`.

Tout autre nom échoue avec `422`. Les enregistrements dont la valeur ne contient pas le champ (ou n'est pas un
objet) ne correspondent simplement pas.

Une condition s'écrit `[field, operator, value]` avec les opérateurs `==`, `!=`, `<`, `>`,
`<=`, `>=`, `regex` (toujours insensible à la casse) et `in` (la valeur est une liste, dont n'importe quel
élément peut correspondre). Les conditions peuvent être combinées avec `{"AND": [...]}` et
`{"OR": [...]}` ; chacun nécessite au moins deux conditions et peut être imbriqué.

```json
{
  "collection": "tickets",
  "params": {
    "filters": [
      ["value.status", "in", ["open", "waiting"]],
      {"OR": [
        ["value.priority", ">=", 3],
        ["value.customer.email", "regex", "@example\\.com$"]
      ]}
    ],
    "sort_key": "value.created_at",
    "sort_order": 1,
    "limit_from": 0,
    "limit_to": 50
  }
}
```

Points à garder à l'esprit lorsque vous filtrez sur des valeurs :

- **Les types doivent correspondre.** `["value.priority", ">=", 3]` ne correspond pas à un
  `"priority": "3"` stocké. Stockez les nombres comme des nombres et les dates comme des chaînes ISO 8601 ou comme
  des nombres, afin que `<` et `>` les comparent correctement.
- **Les chaînes ressemblant à des UUID restent des chaînes** dans les conditions `value.*`, si bien qu'un UUID stocké sous forme
  de texte est trouvé en tant que texte.
- **Les caractères `<`, `>`, `"` et `'` dans une valeur de filtre de type chaîne** sont échappés avant
  la comparaison, si bien qu'une condition sur un texte qui les contient ne correspond pas. Une valeur de filtre
  de type chaîne ne doit pas non plus commencer par `$`.

### Tri et pagination

Sans `sort_key`, les enregistrements arrivent du plus récent au plus ancien. Avec `sort_key`, ils sont triés selon
ce champ.

`limit_from` et `limit_to` sont des positions dans le résultat : la deuxième page de 50
est donc `"limit_from": 50, "limit_to": 100`. Comme `limit_to` ne peut pas dépasser `1000`,
la pagination par limites n'atteint que les 1000 premiers enregistrements correspondants. Pour aller plus loin,
paginez par valeur : triez sur un champ et filtrez sur la dernière valeur reçue, par
exemple `["value.created_at", "<", "2026-09-30T12:00:00Z"]` avec
`"sort_key": "value.created_at", "sort_order": 1`.

Si vous n'envoyez ni `limit_from` ni `limit_to`, `find` renvoie **tous** les enregistrements
correspondants. Ne le faites que pour des collections que vous savez petites.

`count` applique les filtres et ignore le tri et la pagination : il renvoie le nombre total
d'enregistrements correspondants.

## En-têtes de requête et erreurs communs

Tous les endpoints de cette page nécessitent ces en-têtes :

| En-tête | Description |
|---|---|
| `uni-api-key` | Votre clé API. Voir [Authentification](authentication.md). |
| `panel-id` | ID du panneau dont vous accédez à la base de données. |
| `Content-Type` | `application/json` |

Les erreurs sont du JSON de la forme `{"detail": "..."}` (voir [Erreurs](conventions.md#erreurs)).
Celles-ci peuvent provenir de tous les endpoints :

| Statut | `detail` | Cause |
|---|---|---|
| `401` | `API key not provided` / `API key is invalid` | La clé est vide, inconnue, désactivée ou n'a pas la portée de l'endpoint. |
| `403` | `permission denied` | Vous n'êtes pas propriétaire du panneau et il n'est pas partagé avec vous. |
| `403` | `database access is not allowed for this panel` | L'accès à la base de données du panneau est réglé sur **Pas de base de données**. |
| `403` | `operation '<operation>' is disabled for collection '<collection>'` | Une règle de collection d'un panneau en mode public règle cette opération sur **none**. L'opération est `create`, `read`, `update`, `delete`, `count` ou `lock`. |
| `404` | `panel not found` | Aucun panneau avec cet ID, ou le panneau est désactivé. |
| `422` | erreur de validation | L'en-tête `panel-id` est absent ou n'est pas un UUID, ou le corps ne correspond pas au modèle de requête. |
| `422` | `Collection name can only contain alphanumeric characters and underscores` | Nom de collection invalide (`Collection cannot be empty` pour un nom vide). |
| `429` | message de limite de débit | Trop de requêtes ; attendez la durée indiquée dans l'en-tête `Retry-After`. Voir [Limites de débit](conventions.md#limites-de-debit). |
| `500` | erreur serveur | Échec inattendu ; réessayez plus tard. |

## Rechercher des enregistrements

Renvoie les enregistrements d'une collection qui correspondent à la requête.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/find` |
| Portée | `ayeto.booster.database` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `collection` | chaîne | oui | Nom de la collection. |
| `params` | objet | non | Filtres, tri et pagination ; voir [Interroger les enregistrements](#interroger-les-enregistrements). Sans lui, tous les enregistrements de la collection sont renvoyés, du plus récent au plus ancien. |

### Réponse

Un tableau d'[enregistrements](#collections-et-enregistrements) (`key`, `value`). Une collection inconnue ou vide
renvoie `[]`.

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `422` | `Field '<field>' is not allowed for filtering or sorting` | Un filtre ou `sort_key` désigne autre chose que `key` ou `value.<path>`. |
| `422` | `Each filter condition must be a list of [field, operator, value]` | Condition mal formée. |
| `422` | `Operator '<operator>' is not allowed` | Opérateur inconnu. |
| `422` | `'<AND/OR>' operator requires a list of at least 2 conditions` | La valeur de `AND` / `OR` n'est pas une liste d'au moins deux conditions. |
| `422` | `Logical filter must have exactly one key ('AND' or 'OR')` | Condition logique mal formée, par exemple `{}` ou un objet avec plusieurs clés. |
| `422` | `limit_from must be lower than 1000` | `limit_from` vaut 1000 ou plus (la pagination n'atteint que les 1000 premiers enregistrements). |
| `422` | `Maximum number of filter conditions is 1000 (including conditions inside AND/OR)` | Trop de conditions. |
| `422` | `sort_order must be 0 (ASC) or 1 (DESC)` | `sort_order` invalide. |
| `422` | `limit_to cannot exceed 1000` | `limit_to` supérieur à 1000. |
| `422` | `limit_from cannot be greater than limit_to` | Plage inversée. |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/find" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "collection": "tickets",
    "params": {
      "filters": [["value.status", "==", "open"]],
      "sort_key": "value.priority",
      "sort_order": 1,
      "limit_from": 0,
      "limit_to": 20
    }
  }'
```

```json
[
  {
    "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "open",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  },
  {
    "key": "c9f0f895-fb98-4b91-8f2a-6d3e5c1b0a47",
    "value": {
      "customer": {"name": "Petr Svoboda", "email": "petr@example.org"},
      "status": "open",
      "priority": 1,
      "created_at": "2026-10-01T11:40:00Z"
    }
  }
]
```

## Compter les enregistrements

Renvoie le nombre d'enregistrements d'une collection qui correspondent aux filtres.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/count` |
| Portée | `ayeto.booster.database` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `collection` | chaîne | oui | Nom de la collection. |
| `params` | objet | non | Même objet que pour [Rechercher des enregistrements](#rechercher-des-enregistrements). Seuls les `filters` influent sur le résultat ; le tri et la pagination sont validés mais ignorés. |

### Réponse

Un entier.

### Erreurs

Les erreurs de requête de [Rechercher des enregistrements](#rechercher-des-enregistrements).

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/count" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "tickets", "params": {"filters": [["value.status", "==", "open"]]}}'
```

```json
42
```

## Obtenir un enregistrement

Renvoie un enregistrement d'après sa clé.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/get` |
| Portée | `ayeto.booster.database` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `key` | UUID | oui | Clé de l'enregistrement. |

### Réponse

L'[enregistrement](#collections-et-enregistrements).

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | La base de données de ce panneau ne contient aucun enregistrement avec cette clé. |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/get" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"}'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Créer un enregistrement

Ajoute un enregistrement à une collection.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/create` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `collection` | chaîne | oui | Nom de la collection (lettres, chiffres, underscores). La collection est créée avec son premier enregistrement. |
| `value` | tout type | non | Les données à stocker. Absent équivaut à `null`. |
| `key` | UUID | non | Clé du nouvel enregistrement. Générée si elle est absente ; elle ne doit être utilisée par aucun enregistrement existant d'aucun panneau (sinon `422`). |

### Réponse

L'[enregistrement](#collections-et-enregistrements) créé, y compris sa `key`.

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `422` | `a record with key '<key>' already exists` | La clé envoyée est utilisée par un enregistrement existant (de ce panneau ou d'un autre). |
| `500` | `error creating database record` | L'enregistrement n'a pas pu être stocké (rarement, deux requêtes ont créé la même clé au même moment). |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/create" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "collection": "tickets",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "open",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  }'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Créer plusieurs enregistrements

Ajoute plusieurs enregistrements, éventuellement dans des collections différentes.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/create-many` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

Le corps est un **tableau** JSON d'objets de même forme que pour
[Créer un enregistrement](#creer-un-enregistrement) (`collection`, `value`, `key` facultative).

Les éléments sont écrits un par un, dans l'ordre. Le lot n'est pas atomique : si un élément
échoue, la requête renvoie l'erreur, les enregistrements qui le précèdent restent créés et les
éléments qui le suivent ne sont pas écrits. Un élément dont la `key` existe déjà arrête le lot
avec `422`. Avant de relancer un lot en échec, vérifiez ce qui a été
écrit, ou envoyez vos propres clés afin de savoir quels éléments existent.

### Réponse

Un tableau des enregistrements créés, dans l'ordre de la requête.

### Erreurs

Les erreurs de [Créer un enregistrement](#creer-un-enregistrement), pour le premier élément en échec.

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/create-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 12}},
    {"collection": "products", "key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 40}}
  ]'
```

```json
[
  {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 12}},
  {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 40}}
]
```

## Mettre à jour un enregistrement

Remplace la valeur d'un enregistrement.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/update` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `key` | UUID | oui | Clé de l'enregistrement. |
| `value` | tout type | non | La nouvelle valeur. Elle remplace entièrement la valeur stockée ; les champs que vous omettez sont supprimés. Absent équivaut à `null`. |

Pour modifier un seul champ, lisez l'enregistrement, modifiez le champ et renvoyez la valeur
entière. Si d'autres clients peuvent écrire le même enregistrement au même moment, faites-le sous un
[verrou](#verrous).

### Réponse

L'[enregistrement](#collections-et-enregistrements) mis à jour.

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | La base de données de ce panneau ne contient aucun enregistrement avec cette clé. |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/update" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "closed",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  }'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "closed",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Mettre à jour plusieurs enregistrements

Remplace les valeurs de plusieurs enregistrements.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/update-many` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

Le corps est un **tableau** JSON d'objets de même forme que pour
[Mettre à jour un enregistrement](#mettre-a-jour-un-enregistrement) (`key`, `value`). Les éléments sont écrits un par un, dans
l'ordre, et le lot n'est pas atomique : un élément en échec arrête la requête, les éléments qui le
précèdent restent mis à jour.

### Réponse

Un tableau des enregistrements mis à jour, dans l'ordre de la requête.

### Erreurs

Les erreurs de [Mettre à jour un enregistrement](#mettre-a-jour-un-enregistrement), pour le premier élément en échec.

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/update-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 10}},
    {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 37}}
  ]'
```

```json
[
  {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 10}},
  {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 37}}
]
```

## Supprimer un enregistrement

Supprime un enregistrement.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/delete` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `key` | UUID | oui | Clé de l'enregistrement. |

### Réponse

`null`.

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | La base de données de ce panneau ne contient aucun enregistrement avec cette clé (y compris lorsqu'il a déjà été supprimé). |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/delete" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"}'
```

## Supprimer plusieurs enregistrements

Supprime plusieurs enregistrements.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/delete-many` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

Le corps est un **tableau** JSON d'objets avec un champ `key`, comme pour
[Supprimer un enregistrement](#supprimer-un-enregistrement). Les enregistrements sont supprimés un par un, dans l'ordre ; un élément
en échec (par exemple une clé qui n'existe pas) arrête la requête, et les enregistrements qui le précèdent
restent supprimés.

### Réponse

`null`.

### Erreurs

Les erreurs de [Supprimer un enregistrement](#supprimer-un-enregistrement), pour le premier élément en échec.

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/delete-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30"},
    {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54"}
  ]'
```

## Verrous

Les verrous permettent à plusieurs clients (vos serveurs, le panneau lui-même) d'accéder
à tour de rôle aux mêmes données. Un verrou a une durée de vie et est de l'un des deux types suivants :

- **Verrou d'enregistrement** : `collection` et `key`. Utilisez la collection réelle de l'enregistrement ; un
  verrou pris sous un autre nom de collection ne protège pas l'enregistrement.
- **Verrou de collection** : `collection` sans `key`.

Les verrous sont **consultatifs** : le serveur ne les vérifie pas. `get`, `find`, `count`,
`create`, `update`, `delete` et leurs variantes par lot passent, qu'un enregistrement ou une
collection soit verrouillé ou non. Un verrou fait seulement attendre les autres appels `lock/acquire` pour le même
verrou ; il ne protège donc les données que si chaque client qui les écrit prend d'abord le verrou ;
cette coordination incombe au panneau et à votre code.

- **Les verrous d'enregistrement et de collection sont indépendants.** Détenir un verrou de collection n'empêche
  personne de prendre un verrou d'enregistrement dans cette collection, et inversement.
- **N'importe qui peut libérer un verrou.** `release` libère le verrou quel que soit celui qui l'a pris, et un
  second `acquire` d'un verrou que vous détenez déjà attend comme pour celui de n'importe qui d'autre. Ne libérez un verrou
  que depuis le code qui l'a acquis.
- **Les verrous expirent.** Chaque verrou est libéré automatiquement après son `ttl`, même s'il n'a
  jamais été libéré. Choisissez un TTL plus long que la durée de votre traitement, mais assez court pour qu'un
  client planté ne bloque pas les autres trop longtemps.

Le verrouillage correspond à l'opération `lock` des [règles de collection](#regles-de-collection) ; les deux
endpoints nécessitent la portée d'écriture et le même accès au panneau que l'écriture.

## Acquérir un verrou

Prend un verrou d'enregistrement ou de collection, en attendant qu'il soit libre.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/lock/acquire` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `collection` | chaîne | oui | Nom de la collection. |
| `key` | UUID | non | Clé de l'enregistrement. Omettez-la pour prendre le verrou de collection. |
| `acquire_timeout` | nombre | non | Nombre de secondes d'attente du verrou. `0` échoue immédiatement si le verrou est pris. S'il est absent, la requête attend aussi longtemps que nécessaire, jusqu'au TTL restant du détenteur actuel ; envoyez toujours une valeur plus courte que votre délai d'expiration HTTP. |
| `ttl` | nombre | non | Nombre de secondes après lesquelles le verrou est libéré automatiquement. Doit être supérieur à `0.1`. `3600` par défaut. |

### Réponse

`null`, une fois le verrou à vous.

### Erreurs

| Statut | `detail` | Cause |
|---|---|---|
| `423` | `Failed to acquire lock for collection '<collection>' and key '<key>'` | Le verrou est resté pris pendant `acquire_timeout` secondes (`<key>` vaut `None` pour un verrou de collection). Réessayez plus tard. |
| `422` | erreur de validation | `ttl` vaut `0.1` ou moins. |

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/lock/acquire" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "acquire_timeout": 10, "ttl": 30}'
```

## Libérer un verrou

Libère un verrou d'enregistrement ou de collection, quel que soit son détenteur.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/lock/release` |
| Portée | `ayeto.booster.database.write` |
| Limite de débit | [par défaut](conventions.md#limites-de-debit) |

### Requête

| Champ | Type | Obligatoire | Description |
|---|---|---|---|
| `collection` | chaîne | oui | Nom de la collection, tel qu'envoyé à `lock/acquire`. |
| `key` | UUID | non | Clé de l'enregistrement, telle qu'envoyée à `lock/acquire`. Omettez-la pour le verrou de collection. |

### Réponse

`null`. Libérer un verrou qui n'est pas pris (ou qui a expiré) réussit également.

### Exemples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/lock/release" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30"}'
```

### Lecture-modification-écriture sous verrou

`update` remplace la valeur entière : deux clients qui lisent chacun un enregistrement, le modifient
et le réécrivent peuvent donc écraser mutuellement leurs modifications. Prenez le verrou d'enregistrement autour de toute
la séquence et libérez-le dans un bloc `finally` ; chaque client qui écrit l'enregistrement doit faire
de même :

```python
import os
import time

import requests

BASE_URL = "https://ayeto.ai/api/v3/booster/database/private"
HEADERS = {
    "uni-api-key": os.environ["AYETO_API_KEY"],
    "panel-id": "3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90",
}


def call(path: str, body):
    response = requests.post(f"{BASE_URL}/{path}", json=body, headers=HEADERS, timeout=30)
    response.raise_for_status()
    return response.json()


def change_stock(key: str, delta: int, attempts: int = 3) -> dict:
    """Add `delta` to the quantity of a product record without losing concurrent changes."""
    lock = {"collection": "products", "key": key}
    for attempt in range(attempts):
        try:
            # wait at most 10 s; the lock frees itself after 30 s if this process dies
            call("lock/acquire", {**lock, "acquire_timeout": 10, "ttl": 30})
            break
        except requests.HTTPError as e:
            if e.response.status_code != 423 or attempt == attempts - 1:
                raise
            time.sleep(2)

    try:
        record = call("get", {"key": key})
        value = record["value"]
        value["quantity"] = value.get("quantity", 0) + delta
        return call("update", {"key": key, "value": value})
    finally:
        call("lock/release", lock)


print(change_stock("0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", -2))
```
