diff --git a/README.md b/README.md index fd26def..4e36838 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,22 @@ Tout tient dans un fichier JSON, `data/projets.json`, décrit dans parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un diff git. -À chaque sauvegarde, le serveur copie la version précédente dans un dossier `backups/` voisin du -planning, horodatée. +**À l'ouverture**, le serveur copie le planning dans un dossier `backups/` voisin, horodaté : c'est +l'état d'avant la séance, celui qu'on veut retrouver si elle tourne mal. Si le serveur reste allumé +plusieurs jours, une copie est reprise au plus une fois par jour. Rouvrir l'outil sans avoir rien +modifié ne laisse pas de trace. + +Cinquante sauvegardes sont conservées, les plus anciennes étant supprimées au fil de l'eau. Comme +elles valent une par séance et non une par modification, cela représente des mois d'historique +plutôt que la dernière demi-heure. + +Pour restaurer, **arrêter `frise` et fermer l'onglet d'abord** : l'application garde son planning +en mémoire et sa prochaine sauvegarde écraserait ce qu'on vient de remettre en place. + +```sh +ls -lt backups/ # repérer la version voulue +cp backups/projets-20260801-170352.json projets.json +``` Le planning n'est **pas versionné** : `.gitignore` exclut `projets.json`, ce dépôt ne contenant que le code. Seul `data/exemple.json` y figure, comme jeu de démonstration. diff --git a/docs/decisions.md b/docs/decisions.md index 9c8dbc4..dd8f00c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -64,8 +64,9 @@ serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en u — soit **moins** de code que l'API navigateur plus son repli plus son miroir. Et il fonctionne partout, sans sélecteur à réautoriser. -Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée à chaque écriture, ce que -la File System Access API ne permettait pas simplement. +Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée, ce que la File System +Access API ne permettait pas simplement. Elle l'était alors à chaque écriture ; son rythme a depuis +changé, voir décision 23. ## 4. DOM et CSS plutôt que SVG @@ -500,3 +501,33 @@ sans recompiler. `serve.py` a été supprimé une fois le binaire validé en usage réel. Il n'y avait rien à migrer : le format du fichier et les routes sont identiques, et son historique reste dans git si le besoin de le relire se présentait. + +## 23. Une sauvegarde par séance, pas une par modification + +Le serveur copiait le planning dans `backups/` avant **chaque** écriture. Comme l'interface +enregistre après une seconde d'inactivité, une séance d'édition un peu soutenue produisait des +dizaines de fichiers quasi identiques. + +Le défaut n'est pas l'espace occupé — cinquante copies d'un planning macro pèsent 250 Ko — mais ce +que la rotation en fait : les cinquante emplacements se remplissaient en une après-midi, et +chassaient précisément les états anciens qu'on cherche à retrouver. Un filet de sécurité qui ne +remonte pas plus loin que la dernière demi-heure protège de la faute de frappe, pas de la bêtise +qu'on découvre le lendemain. + +La copie est donc prise **à l'ouverture**, et une seule fois. C'est le bon moment : l'état d'avant +la séance est exactement celui vers lequel on veut revenir, et il est figé avant qu'on y touche. +Deux garde-fous complètent la règle : + +- si le serveur reste allumé longtemps — un serveur local qu'on ne ferme jamais est un cas + réel —, une copie est reprise au plus une fois par jour, faute de quoi une session de trois + semaines ne laisserait qu'un seul point de reprise ; +- si le planning est identique à la dernière sauvegarde, rien n'est écrit. Rouvrir l'outil sans + avoir rien modifié n'a pas à consommer un emplacement. + +À raison d'une copie par séance, les cinquante conservées couvrent des mois. Pour un historique +plus long ou plus fin, la vraie réponse n'est pas d'en garder davantage mais de versionner le +planning dans git, ce que son format texte indenté rend parfaitement lisible (voir décision 5). + +Ce changement a rendu concurrentes deux étapes qui ne l'étaient pas — la copie et le remplacement +dépendent maintenant d'un état partagé, la date de la dernière sauvegarde. Un verrou les sérialise, +ce qui protège au passage du cas où deux onglets enregistrent en même temps. diff --git a/main.go b/main.go index ee47891..114d55f 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ import ( "runtime" "sort" "strings" + "sync" "syscall" "time" ) @@ -47,6 +48,16 @@ const tailleMax = 5 << 20 // sont supprimées pour que backups/ ne grossisse pas indéfiniment. const backupsConserves = 50 +// Intervalle minimal entre deux sauvegardes tant que le serveur tourne. +// +// L'interface enregistre après une seconde d'inactivité : copier le planning à +// chaque écriture produisait des dizaines de fichiers quasi identiques par +// séance, qui chassaient les états anciens et ne remontaient pas plus loin que +// la dernière demi-heure. Une sauvegarde est prise à l'ouverture — l'état +// d'avant la séance, celui qu'on veut retrouver — puis au plus une par jour si +// le serveur reste allumé longtemps. Voir docs/decisions.md, section 23. +const intervalleBackup = 24 * time.Hour + // Premier port tenté. En cas d'occupation on essaie les suivants : lancé par // un double-clic, l'utilisateur n'a aucun moyen de passer --port. const portInitial = 8000 @@ -57,6 +68,12 @@ var donneesInitiales = []byte(`{"version": 3, "projects": []}`) type serveur struct { fichierDonnees string interfaceWeb fs.FS + + // Sérialise les écritures : la copie de sauvegarde et le remplacement du + // fichier forment un tout, que deux onglets ouverts ne doivent pas + // entrelacer. + mu sync.Mutex + dernierBackup time.Time } func main() { @@ -88,6 +105,14 @@ func main() { url := fmt.Sprintf("http://localhost:%d", ecouteur.Addr().(*net.TCPAddr).Port) fmt.Printf("Frise multi-projets -> %s\n", url) fmt.Printf("Données : %s\n", fichier) + + // L'état d'avant la séance : c'est celui qu'on voudra retrouver si la + // séance tourne mal. Un échec ici n'empêche pas de travailler, mais il doit + // se voir — l'utilisateur croirait sinon avoir un filet qu'il n'a pas. + if err := srv.sauvegarderVersionPrecedente(true); err != nil { + fmt.Fprintf(os.Stderr, "Attention : sauvegarde d'ouverture impossible (%v)\n", err) + } + fmt.Println("Ctrl+C pour arrêter.") fmt.Println() @@ -287,11 +312,14 @@ func (s *serveur) ecrireDonnees(w http.ResponseWriter, r *http.Request) { } indente.WriteByte('\n') - if err := s.sauvegarderVersionPrecedente(); err != nil { - erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) - return + s.mu.Lock() + err = s.sauvegarderVersionPrecedente(false) + if err == nil { + err = s.ecrireAtomiquement(indente.Bytes()) } - if err := s.ecrireAtomiquement(indente.Bytes()); err != nil { + s.mu.Unlock() + + if err != nil { erreur(w, http.StatusInternalServerError, fmt.Sprintf("Écriture impossible : %v", err)) return } @@ -308,9 +336,18 @@ func (s *serveur) dossierBackups() string { return filepath.Join(filepath.Dir(s.fichierDonnees), "backups") } -// sauvegarderVersionPrecedente copie le fichier actuel dans backups/ avant de -// l'écraser. -func (s *serveur) sauvegarderVersionPrecedente() error { +// sauvegarderVersionPrecedente copie le fichier actuel dans backups/, si la +// copie apporte quelque chose. +// +// Deux garde-fous évitent d'inonder le dossier : l'intervalle minimal, que +// « forcer » outrepasse à l'ouverture, et la comparaison au dernier état +// sauvegardé — rouvrir l'outil sans avoir rien modifié n'a pas à laisser de +// trace. +func (s *serveur) sauvegarderVersionPrecedente(forcer bool) error { + if !forcer && time.Since(s.dernierBackup) < intervalleBackup { + return nil + } + contenu, err := os.ReadFile(s.fichierDonnees) if errors.Is(err, fs.ErrNotExist) { return nil @@ -319,12 +356,19 @@ func (s *serveur) sauvegarderVersionPrecedente() error { return err } + if precedent, trouve := s.dernierEtatSauvegarde(); trouve && bytes.Equal(precedent, contenu) { + // Rien n'a bougé depuis la dernière copie. On note tout de même le + // passage, pour ne pas relire le dossier à chaque écriture. + s.dernierBackup = time.Now() + return nil + } + dossier := s.dossierBackups() if err := os.MkdirAll(dossier, 0o755); err != nil { return err } - base := strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) + base := s.baseNom() marqueur := time.Now().Format("20060102-150405") cible := filepath.Join(dossier, fmt.Sprintf("%s-%s.json", base, marqueur)) @@ -337,9 +381,33 @@ func (s *serveur) sauvegarderVersionPrecedente() error { if err := os.WriteFile(cible, contenu, 0o644); err != nil { return err } + s.dernierBackup = time.Now() return elaguerBackups(dossier, base) } +// baseNom est le nom du planning sans son extension, préfixe commun à ses +// sauvegardes. +func (s *serveur) baseNom() string { + return strings.TrimSuffix(filepath.Base(s.fichierDonnees), filepath.Ext(s.fichierDonnees)) +} + +// dernierEtatSauvegarde renvoie le contenu de la sauvegarde la plus récente. +// L'horodatage est en tête et de longueur fixe : l'ordre lexicographique est +// l'ordre chronologique. +func (s *serveur) dernierEtatSauvegarde() ([]byte, bool) { + entrees, err := filepath.Glob(filepath.Join(s.dossierBackups(), s.baseNom()+"-*.json")) + if err != nil || len(entrees) == 0 { + return nil, false + } + sort.Strings(entrees) + + contenu, err := os.ReadFile(entrees[len(entrees)-1]) + if err != nil { + return nil, false + } + return contenu, true +} + // ecrireAtomiquement écrit dans un fichier temporaire puis remplace, pour // qu'une coupure en cours d'écriture ne laisse jamais un planning tronqué. func (s *serveur) ecrireAtomiquement(contenu []byte) error { diff --git a/main_test.go b/main_test.go index 0f474d9..3540c00 100644 --- a/main_test.go +++ b/main_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" "testing/fstest" + "time" ) // serveurDeTest monte un serveur sur un dossier temporaire. Le fichier de @@ -253,11 +254,14 @@ func TestElaguerBackupsNeGardeQueLesPlusRecents(t *testing.T) { } } -func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { +// Le point de la décision 23 : une séance d'édition ne doit pas remplir le +// dossier de copies quasi identiques, sous peine d'en chasser les états +// anciens — les seuls qu'on cherche vraiment à retrouver. +func TestEcrituresRapprocheesNeProduisentQuUneSauvegarde(t *testing.T) { s, routes := serveurDeTest(t) ecrireFichier(t, s.fichierDonnees, `{"projects":[]}`) - for i := 0; i < 3; i++ { + for i := 0; i < 10; i++ { rec := appeler(t, routes, http.MethodPut, "/api/data", fmt.Sprintf(`{"version":3,"projects":[],"n":%d}`, i)) if rec.Code != http.StatusOK { @@ -265,6 +269,76 @@ func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { } } + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if len(sauvegardes) != 1 { + t.Errorf("sauvegardes = %d, attendu 1 pour dix écritures rapprochées", len(sauvegardes)) + } + // Et c'est bien l'état d'avant la séance qui est conservé. + if lireFichier(t, sauvegardes[0]) != `{"projects":[]}` { + t.Errorf("la sauvegarde ne contient pas l'état initial : %s", lireFichier(t, sauvegardes[0])) + } +} + +func TestSauvegardeDOuvertureEstToujoursPrise(t *testing.T) { + s, _ := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":["avant"]}`) + + // Une écriture vient d'avoir lieu : l'intervalle n'est pas écoulé. + s.dernierBackup = time.Now() + + if err := s.sauvegarderVersionPrecedente(true); err != nil { + t.Fatalf("sauvegarde d'ouverture : %v", err) + } + + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if len(sauvegardes) != 1 { + t.Fatalf("sauvegardes = %d, attendu 1 — l'ouverture doit passer outre l'intervalle", + len(sauvegardes)) + } +} + +func TestOuvrirSansRienModifierNeDupliquePas(t *testing.T) { + s, _ := serveurDeTest(t) + ecrireFichier(t, s.fichierDonnees, `{"projects":["inchangé"]}`) + + // Trois ouvertures successives sur un planning qu'on n'a pas touché. + for i := 0; i < 3; i++ { + if err := s.sauvegarderVersionPrecedente(true); err != nil { + t.Fatalf("ouverture %d : %v", i, err) + } + } + + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) + if len(sauvegardes) != 1 { + t.Errorf("sauvegardes = %d, attendu 1 — le contenu est identique", len(sauvegardes)) + } +} + +func TestSansFichierAucuneSauvegarde(t *testing.T) { + s, _ := serveurDeTest(t) + + if err := s.sauvegarderVersionPrecedente(true); err != nil { + t.Fatalf("sauvegarde : %v", err) + } + + if existe(s.dossierBackups()) { + t.Error("un dossier de sauvegardes a été créé alors qu'il n'y a pas de planning") + } +} + +// Deux copies dans la même seconde ne doivent pas s'écraser. Le cas est devenu +// rare depuis l'espacement des sauvegardes, mais il reste atteignable — deux +// exemplaires lancés en même temps sur le même planning. +func TestSauvegardesMultiplesDansLaMemeSeconde(t *testing.T) { + s, _ := serveurDeTest(t) + + for i := 0; i < 3; i++ { + ecrireFichier(t, s.fichierDonnees, fmt.Sprintf(`{"projects":[],"n":%d}`, i)) + if err := s.sauvegarderVersionPrecedente(true); err != nil { + t.Fatalf("sauvegarde %d : %v", i, err) + } + } + sauvegardes, _ := filepath.Glob(filepath.Join(s.dossierBackups(), "projets-*.json")) if len(sauvegardes) != 3 { t.Errorf("sauvegardes = %d, attendu 3 — des copies se sont écrasées", len(sauvegardes))