package docs import ( "database/sql" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" ) // Version-history tuning. const ( // autoSnapshotInterval is the minimum gap between background ('auto') // snapshots. Auto-save fires every ~1.5s; without a floor we'd store a // version per keystroke-burst. A few minutes keeps history useful for // recovery without unbounded growth. autoSnapshotInterval = 3 * time.Minute // maxAutoVersions caps how many 'auto' snapshots we retain per document. // 'manual' and 'pre_restore' versions are never pruned — they're explicit // restore points the writer (or a restore) deliberately created. maxAutoVersions = 40 ) // versionRoutes registers the history endpoints on the docs sub-router. Paths // resolve to /api/docs/{id}/versions... func (h *Handler) versionRoutes(r chi.Router) { r.Get("/{id}/versions", h.listVersions) r.Post("/{id}/versions", h.createVersion) // explicit "save a restore point" r.Get("/{id}/versions/{vid}", h.getVersion) // full body for preview r.Post("/{id}/versions/{vid}/restore", h.restoreVersion) } // listVersions returns the document's snapshots, newest first, without the heavy // content fields (those load on preview/restore). func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") // Scope through the documents table so a snapshot is only visible to the // owner of its parent document. rows, err := h.DB.Query( `SELECT v.id, v.doc_id, v.title, v.word_count, v.kind, v.created_at FROM document_versions v JOIN documents d ON d.id = v.doc_id WHERE v.doc_id = ? AND d.user_id = ? ORDER BY v.created_at DESC`, docID, db.LocalUserID, ) if err != nil { serverError(w, err) return } defer rows.Close() out := []db.DocumentVersion{} // non-nil so an empty history serializes as [] for rows.Next() { var v db.DocumentVersion if err := rows.Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt); err != nil { serverError(w, err) return } out = append(out, v) } if err := rows.Err(); err != nil { serverError(w, err) return } writeJSON(w, http.StatusOK, out) } // getVersion returns one snapshot in full (including content) for preview. func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) { v, err := h.fetchVersion(chi.URLParam(r, "id"), chi.URLParam(r, "vid")) if errors.Is(err, sql.ErrNoRows) { notFoundMsg(w, "version not found") return } if err != nil { serverError(w, err) return } writeJSON(w, http.StatusOK, v) } // createVersion takes an explicit, user-requested ('manual') restore point from // the document's current saved state. func (h *Handler) createVersion(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") doc, err := h.fetch(docID) if errors.Is(err, sql.ErrNoRows) { notFound(w) return } if err != nil { serverError(w, err) return } v, err := h.insertVersion(doc, db.VersionKindManual) if err != nil { serverError(w, err) return } writeJSON(w, http.StatusCreated, v) } // restoreVersion copies a snapshot back onto the live document. Before // overwriting, it captures the current state as a 'pre_restore' version so the // restore is itself undoable. Returns the restored document. func (h *Handler) restoreVersion(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") vid := chi.URLParam(r, "vid") v, err := h.fetchVersion(docID, vid) if errors.Is(err, sql.ErrNoRows) { notFoundMsg(w, "version not found") return } if err != nil { serverError(w, err) return } current, err := h.fetch(docID) if err != nil { serverError(w, err) return } if _, err := h.insertVersion(current, db.VersionKindPreRestore); err != nil { serverError(w, err) return } res, err := h.DB.Exec( `UPDATE documents SET title = ?, content = ?, content_text = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`, v.Title, v.Content, v.ContentText, v.WordCount, docID, db.LocalUserID, ) if err != nil { serverError(w, err) return } if n, _ := res.RowsAffected(); n == 0 { notFound(w) return } doc, err := h.fetch(docID) if err != nil { serverError(w, err) return } writeJSON(w, http.StatusOK, doc) } // maybeAutoSnapshot records a throttled background snapshot of the just-saved // document. It no-ops when the newest snapshot is younger than // autoSnapshotInterval, or when the body is unchanged since the last snapshot, // so an idle or rename-only save costs nothing. Best-effort: callers log but do // not fail the save if this errors. Prunes old 'auto' versions on success. func (h *Handler) maybeAutoSnapshot(doc db.Document) error { var ( lastAt time.Time lastText string hasPrev bool ) err := h.DB.QueryRow( `SELECT created_at, content_text FROM document_versions WHERE doc_id = ? ORDER BY created_at DESC LIMIT 1`, doc.ID, ).Scan(&lastAt, &lastText) switch { case errors.Is(err, sql.ErrNoRows): hasPrev = false case err != nil: return err default: hasPrev = true } if hasPrev { if lastText == doc.ContentText { return nil // nothing meaningful changed } if time.Since(lastAt) < autoSnapshotInterval { return nil // too soon; let edits accumulate } } if _, err := h.insertVersion(doc, db.VersionKindAuto); err != nil { return err } return h.pruneAutoVersions(doc.ID) } // insertVersion writes a snapshot row of the given kind and returns it (without // the heavy content fields, matching the list shape). func (h *Handler) insertVersion(doc db.Document, kind string) (db.DocumentVersion, error) { var v db.DocumentVersion err := h.DB.QueryRow( `INSERT INTO document_versions (doc_id, title, content, content_text, word_count, kind) VALUES (?, ?, ?, ?, ?, ?) RETURNING id, doc_id, title, word_count, kind, created_at`, doc.ID, doc.Title, doc.Content, doc.ContentText, doc.WordCount, kind, ).Scan(&v.ID, &v.DocID, &v.Title, &v.WordCount, &v.Kind, &v.CreatedAt) return v, err } // pruneAutoVersions trims a document's 'auto' snapshots to the newest // maxAutoVersions, leaving 'manual' and 'pre_restore' restore points intact. func (h *Handler) pruneAutoVersions(docID string) error { _, err := h.DB.Exec( `DELETE FROM document_versions WHERE doc_id = ? AND kind = 'auto' AND id NOT IN ( SELECT id FROM document_versions WHERE doc_id = ? AND kind = 'auto' ORDER BY created_at DESC LIMIT ? )`, docID, docID, maxAutoVersions, ) return err } // fetchVersion loads one full snapshot, scoped to its owner via the parent doc. func (h *Handler) fetchVersion(docID, vid string) (db.DocumentVersion, error) { var v db.DocumentVersion err := h.DB.QueryRow( `SELECT v.id, v.doc_id, v.title, v.content, v.content_text, v.word_count, v.kind, v.created_at FROM document_versions v JOIN documents d ON d.id = v.doc_id WHERE v.id = ? AND v.doc_id = ? AND d.user_id = ?`, vid, docID, db.LocalUserID, ).Scan( &v.ID, &v.DocID, &v.Title, &v.Content, &v.ContentText, &v.WordCount, &v.Kind, &v.CreatedAt, ) return v, err }