Détection de conflits #5

Open
opened 2026-08-28 22:00:56 +01:00 by maxime · 2 comments
Owner

Description

Deux membres qui modifient le même élément au même moment : le dernier écrit écrase le premier, sans que personne ne le sache. Le polling (3 s sur la liste ouverte) réduit la fenêtre, il ne la ferme pas — et sur l'écran de modification d'un élément, ouvert plusieurs minutes, elle reste grande ouverte.

Ce que ça débloque
Deux chantiers attendent explicitement cette brique :

  • L'écriture hors ligne (#15). D35 l'a écartée pour cette raison précise : une écriture mise en file puis rejouée, c'est le client décidant de ce qui s'est passé pendant son absence, ce que D26 refuse.
  • Un import qui met à jour au lieu d'ajouter (D36, cf. #2) : distinguer « ceci a changé depuis l'export » de « c'est ce que disait l'export » est exactement de la détection de conflit.

L'existant sur lequel s'appuyer

  • Une liste porte déjà un compteur revision, incrémenté à chaque mutation et comparé par le polling. C'est la moitié d'un versionnage optimiste : il manque de l'envoyer avec la mutation et de refuser celle-ci s'il a bougé.
  • D26 rend le serveur autoritaire — toute mutation renvoie le snapshot complet et le client remplace son état. Un refus pour conflit est donc déjà servi par le chemin normal, sans branchement sur le code HTTP dans les stores.

Ce qu'il faut trancher

  • La granularité. revision est au niveau de la liste : deux personnes cochant deux articles différents de la même liste entreraient en conflit pour rien. Un compteur par élément est plus juste et plus coûteux.
  • Ce qui est en conflit et ce qui ne l'est pas. Cocher est idempotent et ne doit rien refuser ; renommer, réassigner ou changer une échéance, si.
  • Le réordonnancement, qui envoie l'ordre entier, entre en conflit avec toute insertion concurrente par construction.
  • Ce que voit l'utilisateur. Un message en français qui dit ce qui a changé, pas un code. La valeur qu'il vient de saisir doit rester récupérable — sinon le refus coûte plus cher que l'écrasement.

Plan d'action

Détail : conception technique.

  • Backend : base facultatif dans PUT /api/items/:id, comparaison par champ dans la transaction d'écriture, refus 409 Conflict (§1, §2)
  • Backend : réordonnancement normalisé — ids reçus d'abord, les autres à la suite, positions uniques (§3)
  • Frontend : la fiche retient sa base et n'envoie que ses changements ; zone de message propre à la fiche (§4)
  • Frontend : bandeau « Modifié entre-temps », « Reprendre la leur », élément supprimé → « Le recréer » ; chaînes l10n (§4)
  • Tests unitaires et e2e (§6)
  • Ouvrir les tickets de détection de conflits pour les recettes (#18) et pour les événements (#19)

Recette

  • make lint typecheck test, make build-frontend (bundle IIFE), make l10n-check, cd e2e && npm test
  • Vérifier à travers le proxy AppAPI que le 409 et son message arrivent intacts
  • Vérifier à la main, deux membres dans deux navigateurs : échéance, titre, suppression concurrentes
  • /code-review, retours en commentaire
  • Revue technique personnelle
  • Mise à jour du wiki : nouvelle décision dans Architecture, renvois depuis D35 et D36, la fiche d'élément dans UX ; paragraphe « What is left » d'AGENTS.md
  • Revue fonctionnelle

Plan de MEP

  • Aucune migration, aucune modification d'info.xml : une nouvelle image suffit
  • Un onglet resté ouvert sur l'ancien bundle écrit sans base, donc sans condition, comme aujourd'hui : pas de coupure, la protection arrive au rechargement
  • Livraison avec la version du jalon
## Description Deux membres qui modifient le même élément au même moment : le dernier écrit écrase le premier, sans que personne ne le sache. Le polling (3 s sur la liste ouverte) réduit la fenêtre, il ne la ferme pas — et sur l'écran de modification d'un élément, ouvert plusieurs minutes, elle reste grande ouverte. **Ce que ça débloque** Deux chantiers attendent explicitement cette brique : - **L'écriture hors ligne** (#15). D35 l'a écartée pour cette raison précise : une écriture mise en file puis rejouée, c'est le client décidant de ce qui s'est passé pendant son absence, ce que D26 refuse. - **Un import qui met à jour** au lieu d'ajouter (D36, cf. #2) : distinguer « ceci a changé depuis l'export » de « c'est ce que disait l'export » est exactement de la détection de conflit. **L'existant sur lequel s'appuyer** - Une liste porte déjà un compteur `revision`, incrémenté à chaque mutation et comparé par le polling. C'est la moitié d'un versionnage optimiste : il manque de l'envoyer avec la mutation et de refuser celle-ci s'il a bougé. - D26 rend le serveur autoritaire — toute mutation renvoie le snapshot complet et le client remplace son état. Un refus pour conflit est donc déjà servi par le chemin normal, sans branchement sur le code HTTP dans les stores. **Ce qu'il faut trancher** - **La granularité.** `revision` est au niveau de la liste : deux personnes cochant deux articles différents de la même liste entreraient en conflit pour rien. Un compteur par élément est plus juste et plus coûteux. - **Ce qui est en conflit et ce qui ne l'est pas.** Cocher est idempotent et ne doit rien refuser ; renommer, réassigner ou changer une échéance, si. - **Le réordonnancement**, qui envoie l'ordre entier, entre en conflit avec toute insertion concurrente par construction. - **Ce que voit l'utilisateur.** Un message en français qui dit ce qui a changé, pas un code. La valeur qu'il vient de saisir doit rester récupérable — sinon le refus coûte plus cher que l'écrasement. ## Plan d'action Détail : [conception technique](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/5#issuecomment-152). - [x] Backend : `base` facultatif dans `PUT /api/items/:id`, comparaison par champ dans la transaction d'écriture, refus 409 `Conflict` (§1, §2) - [x] Backend : réordonnancement normalisé — ids reçus d'abord, les autres à la suite, positions uniques (§3) - [x] Frontend : la fiche retient sa base et n'envoie que ses changements ; zone de message propre à la fiche (§4) - [x] Frontend : bandeau « Modifié entre-temps », « Reprendre la leur », élément supprimé → « Le recréer » ; chaînes l10n (§4) - [x] Tests unitaires et e2e (§6) - [x] Ouvrir les tickets de détection de conflits pour les recettes (#18) et pour les événements (#19) ## Recette - [x] `make lint typecheck test`, `make build-frontend` (bundle IIFE), `make l10n-check`, `cd e2e && npm test` - [x] Vérifier à travers le proxy AppAPI que le 409 et son message arrivent intacts - [x] Vérifier à la main, deux membres dans deux navigateurs : échéance, titre, suppression concurrentes - [x] `/code-review`, retours en commentaire - [x] Revue technique personnelle - [x] Mise à jour du wiki : nouvelle décision dans `Architecture`, renvois depuis D35 et D36, la fiche d'élément dans `UX` ; paragraphe « What is left » d'`AGENTS.md` - [x] Revue fonctionnelle ## Plan de MEP - Aucune migration, aucune modification d'`info.xml` : une nouvelle image suffit - Un onglet resté ouvert sur l'ancien bundle écrit sans `base`, donc sans condition, comme aujourd'hui : pas de coupure, la protection arrive au rechargement - Livraison avec la version du jalon
maxime added this to the 1.0.x milestone 2026-08-28 22:00:56 +01:00
Collaborator

Conception technique

Choix arrêtés avec Maxime (2026-09-22)

  • Périmètre : les éléments de liste seuls. Recettes (même forme de problème) et événements (ETag CalDAV, autre mécanisme) feront chacun l'objet d'un ticket ; le mécanisme ci-dessous est écrit pour être repris.
  • Granularité : par champ, sans migration.
  • Refus : la fiche reste ouverte avec la saisie, un bandeau dit ce qui a changé (pas qui), un second « Enregistrer » écrase en connaissance de cause.
  • Réordonnancement : jamais refusé, normalisé.
  • Élément supprimé pendant la modification : bandeau et bouton « Le recréer ».

1. Le contrat de PUT /api/items/:id

Le corps garde ses champs à plat et gagne un objet facultatif base : pour chaque champ modifié, la valeur qu'avait la fiche à l'ouverture.

{ "dueDate": "2026-09-27", "base": { "dueDate": "2026-09-20" } }
  • Un champ présent dans le corps et dans base est conditionnel : refusé si sa valeur actuelle diffère de base et de la nouvelle valeur. Deux membres qui font le même changement ne sont pas en conflit.
  • Un champ sans base s'écrit sans condition. C'est toujours le cas de checked — base n'accepte pas cette clé : cocher envoie un état cible, idempotent, jamais refusé. C'est aussi le cas d'une requête venue d'un bundle antérieur resté ouvert : elle écrit comme aujourd'hui.
  • base est .strict() et reprend les bornes de list-schema.ts.
  • Refus : 409, Conflict extends LocalizedError, message générique traduit (« This item was changed while you were editing it. »). Tout ou rien : un seul champ en conflit refuse la modification entière — sinon la fiche serait à moitié enregistrée, et le bandeau devrait dire quelle moitié.
  • Pas de charge utile structurée dans le 409. Sur un échec, write() relit déjà le snapshot (resync()) ; c'est de ce snapshot autoritatif que la fiche déduit quoi afficher. Aucun branchement sur le statut HTTP (D26).
  • Pas de changement d'info.xml : PUT sur ^/api/.* est déjà déclaré.

2. La comparaison est atomique avec l'écriture

patchItem lit l'élément avant l'await de familyForUser : une autre requête peut écrire entre les deux, et comparer à cet item-là laisserait passer exactement le conflit qu'on cherche. La comparaison se fait donc dans la transaction de db/list-items.ts (updateItem ou une variante) : relire la ligne, comparer, écrire, touchList, sans rendre la main — better-sqlite3 est synchrone, la transaction est réellement atomique. La fonction renvoie les champs en conflit, le service lève le 409 ; rien n'est alors projeté ni notifié.

C'est ce morceau que l'import qui met à jour (#2) et la file hors ligne (#15) réutiliseront.

Égalité : sur les valeurs telles que le schéma les produit (chaînes trimées, vide → null, priorité entière).

3. Réordonnancement : tolérer et normaliser

reorderItems réécrit toutes les positions de la liste dans sa transaction : d'abord les ids reçus qui existent encore, doublons retirés, dans l'ordre reçu ; puis les éléments absents de la requête — insérés entre-temps — dans leur ordre relatif actuel. Les ids disparus sont ignorés. Plus de positions en double, qu'un ordre envoyé sur un état périmé peut produire aujourd'hui. Pas de changement d'API.

4. La fiche (ItemDialog / ListDetail)

  • Elle retient sa base : l'élément tel qu'il était à l'ouverture.
  • À l'enregistrement, elle n'envoie que les champs qui diffèrent de la base, chacun avec sa valeur de base. Rien de modifié → fermeture sans requête.
  • En cas d'échec, une fois le resync du store passé, elle compare sa base à l'élément relu :
    • élément absent → bandeau « supprimé entre-temps » (vocabulaire selon le type de liste, dans constants/lists.ts) et bouton « Le recréer » : addItem avec toute la saisie, en fin de liste. C'est un nouvel élément ; la suppression de l'autre membre n'est pas annulée.
    • champ modifié des deux côtés → bandeau « Modifié entre-temps » qui liste chaque champ et sa nouvelle valeur (date formatée, libellé de priorité, membre ; la description est seulement nommée). La saisie reste dans ces champs, et leur base devient la valeur actuelle : un second « Enregistrer » écrase en connaissance de cause, et un troisième changement entre-temps serait encore détecté. « Reprendre la leur » remet ces champs à la valeur actuelle.
    • champ modifié seulement de l'autre côté → la fiche le prend, base comprise, sans rien dire.
    • sinon → le message d'erreur du store.
  • store.error s'affiche aujourd'hui dans Lists.vue, derrière la fenêtre modale : la fiche a besoin de sa propre zone de message, conflit ou pas.
  • Nouvelles chaînes en anglais dans t(…), puis make l10n-build.

5. Ce qui ne change pas

  • Cocher / décocher : le dernier qui écrit gagne, par construction.
  • Supprimer un élément qu'un autre est en train de modifier : la suppression passe (c'est la fiche de l'autre qui le signale, §4).
  • Vider les cochés : inchangé.
  • Aucune migration, aucune colonne.

6. Tests

  • db/list-items.test.ts : champ inchangé → écrit ; changé → refusé sans rien écrire, revision intacte ; même valeur des deux côtés → écrit ; checked jamais conditionnel ; sans base → sans condition. reorderItems : ids inconnus, doublons, éléments non listés placés à la suite, positions uniques.
  • services/list.test.ts : 409 levé, ni projection ni notification.
  • Routes : schéma de base (clé inconnue refusée, checked refusé).
  • Store / fiche : seul le diff part, la fiche reste ouverte avec la saisie sur un refus.
  • e2e lists.spec.ts : fiche ouverte, échéance changée par l'API à travers le proxy, enregistrement → bandeau, saisie conservée, second enregistrement écrase ; « Reprendre la leur » ; suppression par l'API → « Le recréer ». Chacun doit échouer si le bandeau disparaît.
## Conception technique **Choix arrêtés avec Maxime (2026-09-22)** - **Périmètre** : les éléments de liste seuls. Recettes (même forme de problème) et événements (ETag CalDAV, autre mécanisme) feront chacun l'objet d'un ticket ; le mécanisme ci-dessous est écrit pour être repris. - **Granularité** : par champ, sans migration. - **Refus** : la fiche reste ouverte avec la saisie, un bandeau dit ce qui a changé (pas qui), un second « Enregistrer » écrase en connaissance de cause. - **Réordonnancement** : jamais refusé, normalisé. - **Élément supprimé pendant la modification** : bandeau et bouton « Le recréer ». ### 1. Le contrat de `PUT /api/items/:id` Le corps garde ses champs à plat et gagne un objet facultatif `base` : pour chaque champ modifié, la valeur qu'avait la fiche à l'ouverture. ```json { "dueDate": "2026-09-27", "base": { "dueDate": "2026-09-20" } } ``` - Un champ présent dans le corps **et** dans `base` est conditionnel : refusé si sa valeur actuelle diffère de `base` **et** de la nouvelle valeur. Deux membres qui font le même changement ne sont pas en conflit. - Un champ sans `base` s'écrit sans condition. C'est toujours le cas de `checked` — `base` n'accepte pas cette clé : cocher envoie un état cible, idempotent, jamais refusé. C'est aussi le cas d'une requête venue d'un bundle antérieur resté ouvert : elle écrit comme aujourd'hui. - `base` est `.strict()` et reprend les bornes de `list-schema.ts`. - Refus : **409**, `Conflict extends LocalizedError`, message générique traduit (« This item was changed while you were editing it. »). **Tout ou rien** : un seul champ en conflit refuse la modification entière — sinon la fiche serait à moitié enregistrée, et le bandeau devrait dire quelle moitié. - **Pas de charge utile structurée dans le 409.** Sur un échec, `write()` relit déjà le snapshot (`resync()`) ; c'est de ce snapshot autoritatif que la fiche déduit quoi afficher. Aucun branchement sur le statut HTTP (D26). - Pas de changement d'`info.xml` : `PUT` sur `^/api/.*` est déjà déclaré. ### 2. La comparaison est atomique avec l'écriture `patchItem` lit l'élément **avant** l'`await` de `familyForUser` : une autre requête peut écrire entre les deux, et comparer à cet `item`-là laisserait passer exactement le conflit qu'on cherche. La comparaison se fait donc dans la transaction de `db/list-items.ts` (`updateItem` ou une variante) : relire la ligne, comparer, écrire, `touchList`, sans rendre la main — better-sqlite3 est synchrone, la transaction est réellement atomique. La fonction renvoie les champs en conflit, le service lève le 409 ; rien n'est alors projeté ni notifié. C'est ce morceau que l'import qui met à jour (#2) et la file hors ligne (#15) réutiliseront. Égalité : sur les valeurs telles que le schéma les produit (chaînes trimées, vide → `null`, priorité entière). ### 3. Réordonnancement : tolérer et normaliser `reorderItems` réécrit toutes les positions de la liste dans sa transaction : d'abord les ids reçus qui existent encore, doublons retirés, dans l'ordre reçu ; puis les éléments absents de la requête — insérés entre-temps — dans leur ordre relatif actuel. Les ids disparus sont ignorés. Plus de positions en double, qu'un ordre envoyé sur un état périmé peut produire aujourd'hui. Pas de changement d'API. ### 4. La fiche (`ItemDialog` / `ListDetail`) - Elle retient sa **base** : l'élément tel qu'il était à l'ouverture. - À l'enregistrement, elle n'envoie que les champs qui diffèrent de la base, chacun avec sa valeur de base. Rien de modifié → fermeture sans requête. - En cas d'échec, une fois le `resync` du store passé, elle compare sa base à l'élément relu : - **élément absent** → bandeau « supprimé entre-temps » (vocabulaire selon le type de liste, dans `constants/lists.ts`) et bouton « Le recréer » : `addItem` avec toute la saisie, en fin de liste. C'est un nouvel élément ; la suppression de l'autre membre n'est pas annulée. - **champ modifié des deux côtés** → bandeau « Modifié entre-temps » qui liste chaque champ et sa nouvelle valeur (date formatée, libellé de priorité, membre ; la description est seulement nommée). La saisie reste dans ces champs, et leur base devient la valeur actuelle : un second « Enregistrer » écrase en connaissance de cause, et un troisième changement entre-temps serait encore détecté. « Reprendre la leur » remet ces champs à la valeur actuelle. - **champ modifié seulement de l'autre côté** → la fiche le prend, base comprise, sans rien dire. - sinon → le message d'erreur du store. - `store.error` s'affiche aujourd'hui dans `Lists.vue`, **derrière la fenêtre modale** : la fiche a besoin de sa propre zone de message, conflit ou pas. - Nouvelles chaînes en anglais dans `t(…)`, puis `make l10n-build`. ### 5. Ce qui ne change pas - Cocher / décocher : le dernier qui écrit gagne, par construction. - Supprimer un élément qu'un autre est en train de modifier : la suppression passe (c'est la fiche de l'autre qui le signale, §4). - Vider les cochés : inchangé. - Aucune migration, aucune colonne. ### 6. Tests - `db/list-items.test.ts` : champ inchangé → écrit ; changé → refusé sans rien écrire, `revision` intacte ; même valeur des deux côtés → écrit ; `checked` jamais conditionnel ; sans `base` → sans condition. `reorderItems` : ids inconnus, doublons, éléments non listés placés à la suite, positions uniques. - `services/list.test.ts` : 409 levé, ni projection ni notification. - Routes : schéma de `base` (clé inconnue refusée, `checked` refusé). - Store / fiche : seul le diff part, la fiche reste ouverte avec la saisie sur un refus. - e2e `lists.spec.ts` : fiche ouverte, échéance changée par l'API à travers le proxy, enregistrement → bandeau, saisie conservée, second enregistrement écrase ; « Reprendre la leur » ; suppression par l'API → « Le recréer ». Chacun doit échouer si le bandeau disparaît.
Collaborator

Recette — contrôles et revue (2026-09-22)

Branche feat/item-conflicts, 5 commits sur main.

Contrôles

  • make lint typecheck test : 0 erreur (+3 avertissements de taille de fichier), 497 + 402 tests unitaires.
  • make build-frontend : bundle IIFE ; make l10n-check : 429 sources, toutes traduites.
  • e2e complet : 105/106. Le seul échec, families.spec « renaming a family » (la famille n'apparaît pas à sa création), passe au second passage, sans rapport avec les listes : instabilité de l'instance.
  • item-conflicts.spec.ts (6 tests) + lists.spec.ts : 23/23 après les corrections ; le 409 et son message français arrivent intacts à travers le proxy AppAPI.

/code-review — deux retours, corrigés dans 1df6f5f :

  • Un « Recréer » en échec ne disait rien (le bandeau de suppression masquait l'erreur).
  • Une valeur stockée que le formulaire ne sait pas afficher (priorité 0, quantité vide — possibles par import) comptait comme modifiée : champ réécrit et conflit possible sur un champ non touché.

Le test e2e écrit pour le premier en a trouvé un troisième : la liste ouverte supprimée, la fiche retombait sur le vocabulaire « courses » et perdait la saisie. La fiche garde désormais le type avec lequel elle s'est ouverte.

Wiki : D40 (Architecture), renvois dans D35 et D36, règle de la fiche dans UX. AGENTS.md : invariant « une fiche envoie ses changements avec leur base ».

## Recette — contrôles et revue (2026-09-22) Branche `feat/item-conflicts`, 5 commits sur `main`. **Contrôles** - `make lint typecheck test` : 0 erreur (+3 avertissements de taille de fichier), 497 + 402 tests unitaires. - `make build-frontend` : bundle IIFE ; `make l10n-check` : 429 sources, toutes traduites. - e2e complet : 105/106. Le seul échec, `families.spec` « renaming a family » (la famille n'apparaît pas à sa création), passe au second passage, sans rapport avec les listes : instabilité de l'instance. - `item-conflicts.spec.ts` (6 tests) + `lists.spec.ts` : 23/23 après les corrections ; le 409 et son message français arrivent intacts à travers le proxy AppAPI. **`/code-review`** — deux retours, corrigés dans `1df6f5f` : - Un « Recréer » en échec ne disait rien (le bandeau de suppression masquait l'erreur). - Une valeur stockée que le formulaire ne sait pas afficher (priorité 0, quantité vide — possibles par import) comptait comme modifiée : champ réécrit et conflit possible sur un champ non touché. Le test e2e écrit pour le premier en a trouvé un troisième : la liste ouverte supprimée, la fiche retombait sur le vocabulaire « courses » et perdait la saisie. La fiche garde désormais le type avec lequel elle s'est ouverte. **Wiki** : D40 (Architecture), renvois dans D35 et D36, règle de la fiche dans UX. `AGENTS.md` : invariant « une fiche envoie ses changements avec leur base ».
Sign in to join this conversation.
No description provided.