Files
MindSift/internal/api/docsync.go
T
sandrodachandClaude Opus 5.5 1ea0c78b41
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
feat(notes): operation log, block leases and sync API for collaborative editing
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
  operations with conflict rules (version check, block leases, conflict
  copies instead of lost text, restore on late edits, cycle checks),
  in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
  idempotent results per opId (doc_op_results), snapshot, log paging,
  central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
  the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
  (long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
  read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
  idempotency, API protocol incl. long poll, and a randomized simulation
  (4 clients, lost/duplicated requests and responses, offline phases,
  lease expiry) checking convergence, no lost text and exactly-once results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:47:56 +01:00

229 lines
7.3 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/storage"
)
const (
syncMaxOps = 500
syncMaxLog = 1000
syncMaxBody = 8 << 20
longPollMax = 25 * time.Second
maxClientIDLen = 64
)
type syncLeases struct {
Granted []string `json:"granted"`
Denied []string `json:"denied"`
Others []collab.LeaseInfo `json:"others"`
}
type syncResponse struct {
Results []collab.Result `json:"results"`
Ops []storage.DocLogEntry `json:"ops"`
HeadSeq int64 `json:"headSeq"`
HasMore bool `json:"hasMore"`
Resync bool `json:"resync,omitempty"` // client state is ahead of the server: reload the snapshot
Role string `json:"role"`
Shared bool `json:"shared"`
Sessions int `json:"sessions"`
Presence []collab.Presence `json:"presence"`
Leases syncLeases `json:"leases"`
}
// docAccess resolves the caller's role; it writes 404 and returns "" without access.
func (s *Server) docAccess(w http.ResponseWriter, r *http.Request) (userID, docID, role string) {
userID = auth.GetUserID(r.Context())
docID = chi.URLParam(r, "id")
role = s.store.DocRole(userID, docID)
if role == "" {
writeError(w, http.StatusNotFound, storage.ErrDocNotFound.Error())
}
return userID, docID, role
}
func (s *Server) handleDocSnapshot(w http.ResponseWriter, r *http.Request) {
_, docID, role := s.docAccess(w, r)
if role == "" {
return
}
snap, err := s.store.GetDocSnapshot(docID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"docId": snap.DocID,
"title": snap.Title,
"titleVersion": snap.TitleVersion,
"headSeq": snap.HeadSeq,
"blocks": snap.Blocks,
"role": role,
"shared": s.store.IsDocShared(docID),
})
}
// handleDocSync is the single round trip of the sync protocol: send own operations, hold or release
// block leases, and receive every log entry after lastSeq.
func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) {
userID, docID, role := s.docAccess(w, r)
if role == "" {
return
}
var req struct {
ClientID string `json:"clientId"`
LastSeq int64 `json:"lastSeq"`
Ops []collab.Op `json:"ops"`
Leases *struct {
Hold []string `json:"hold"`
Release []string `json:"release"`
} `json:"leases"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, syncMaxBody)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.ClientID == "" || len(req.ClientID) > maxClientIDLen {
writeError(w, http.StatusBadRequest, "clientId fehlt oder ist zu lang")
return
}
if len(req.Ops) > syncMaxOps {
writeError(w, http.StatusRequestEntityTooLarge, "Zu viele Änderungen in einer Anfrage")
return
}
canEdit := storage.CanEditDoc(role)
if len(req.Ops) > 0 && !canEdit {
writeError(w, http.StatusForbidden, "Keine Schreibrechte für diese Notiz (nur Lesen)")
return
}
actor := collab.Actor{UserID: userID, ClientID: req.ClientID}
if u, err := s.store.GetUserByID(userID); err == nil {
actor.Name = u.DisplayName
}
s.leases.Touch(docID, actor)
resp := syncResponse{Results: []collab.Result{}, Role: role, Leases: syncLeases{Granted: []string{}, Denied: []string{}}}
changed := false
if req.Leases != nil && canEdit {
s.leases.ReleaseAllExcept(docID, req.ClientID, req.Leases.Hold)
s.leases.Release(docID, req.ClientID, req.Leases.Release)
resp.Leases.Granted, resp.Leases.Denied = s.leases.Hold(docID, actor, req.Leases.Hold)
changed = true
}
if len(req.Ops) > 0 {
results, _, err := s.store.ApplyDocOps(docID, actor, req.Ops, s.leases.View(docID))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.Results = results
// New blocks (and conflict copies) belong to their author right away
var own []string
for i, res := range results {
if res.Status == collab.StatusApplied && req.Ops[i].Kind == collab.OpInsert {
own = append(own, req.Ops[i].BlockID)
}
if res.ConflictBlockID != "" {
own = append(own, res.ConflictBlockID)
}
if res.Seq > 0 {
changed = true
}
}
if len(own) > 0 {
granted, _ := s.leases.Hold(docID, actor, own)
resp.Leases.Granted = append(resp.Leases.Granted, granted...)
}
}
entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, syncMaxLog)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if req.LastSeq > head {
resp.Resync = true
entries = []storage.DocLogEntry{}
}
resp.Ops = entries
resp.HeadSeq = head
resp.HasMore = len(entries) == syncMaxLog && entries[len(entries)-1].Seq < head
resp.Shared = s.store.IsDocShared(docID)
resp.Sessions, resp.Presence = s.leases.Sessions(docID)
resp.Leases.Others = s.leases.Others(docID, req.ClientID)
if changed {
s.notifyDocChanged(docID, head)
}
writeJSON(w, http.StatusOK, resp)
}
// notifyDocChanged wakes long-poll requests and pokes WebSocket/SSE clients in the note's room.
func (s *Server) notifyDocChanged(docID string, head int64) {
s.notifier.Notify(docID)
s.wsHub.BroadcastDocEvent(docID, "DOC_SEQ", map[string]interface{}{"docId": docID, "headSeq": head})
}
// handleDocChanges is the long-poll fallback: it returns as soon as the note changes (or leases/presence
// change), at the latest after `wait` seconds. Clients then call /sync to fetch the data.
func (s *Server) handleDocChanges(w http.ResponseWriter, r *http.Request) {
_, docID, role := s.docAccess(w, r)
if role == "" {
return
}
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
wait := longPollMax
if sec, err := strconv.Atoi(r.URL.Query().Get("wait")); err == nil && sec >= 0 && time.Duration(sec)*time.Second < wait {
wait = time.Duration(sec) * time.Second
}
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(wait + 10*time.Second))
woken := s.notifier.Changed(docID) // subscribe before reading, so no change can slip through
_, head, err := s.store.GetDocOpsSince(docID, since, 0)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
changed := head > since
if !changed {
select {
case <-woken:
changed = true
case <-time.After(wait):
case <-r.Context().Done():
return
}
_, head, _ = s.store.GetDocOpsSince(docID, since, 0)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"headSeq": head, "changed": changed})
}
// handleDocLeave ends a client's session on a note and frees its leases (tab closed, app in background).
func (s *Server) handleDocLeave(w http.ResponseWriter, r *http.Request) {
_, docID, role := s.docAccess(w, r)
if role == "" {
return
}
var req struct {
ClientID string `json:"clientId"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || req.ClientID == "" {
writeError(w, http.StatusBadRequest, "clientId fehlt")
return
}
s.leases.Leave(docID, req.ClientID)
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}