Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen, Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn Minuten, auf Akku und Datenvolumen des Telefons. Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den Stand, bei dem es zuletzt angefasst wurde (Migration 24). Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit fehlenden Loeschungen. go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und weder sync-token noch supported-report-set. Beide gehen denselben Weg wie getctag und calendar-color. Ohne supported-report-set probiert DAVx5 sync-collection gar nicht erst aus. Zwei Fehler noch vor dem Ausliefern gefunden: * Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung -- xml.EscapeText machte daraus ". Gueltig, aber genug Clients vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung. * pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst. caldav-selbsttest.py: 62 -> 86 Pruefungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
19 KiB
Go
542 lines
19 KiB
Go
package calendar
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
var ErrNotFound = errors.New("calendar: not found")
|
||
|
||
// ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei
|
||
// Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht
|
||
// unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist.
|
||
var ErrConflict = errors.New("calendar: uid already exists in target calendar")
|
||
|
||
type Store struct {
|
||
db *pgxpool.Pool
|
||
}
|
||
|
||
func NewStore(db *pgxpool.Pool) *Store {
|
||
return &Store{db: db}
|
||
}
|
||
|
||
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||
ownerID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendars: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Calendar
|
||
for rows.Next() {
|
||
var c Calendar
|
||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil {
|
||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
|
||
ownerID, id,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die
|
||
// vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID.
|
||
//
|
||
// Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art
|
||
// Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur
|
||
// so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie
|
||
// die UUID eines anderen Kalenders.
|
||
func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at
|
||
FROM cal_calendars
|
||
WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2))
|
||
ORDER BY path_segment NULLS LAST
|
||
LIMIT 1`,
|
||
ownerID, ref,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query calendar by ref: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten**
|
||
// Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben,
|
||
// kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben.
|
||
func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) {
|
||
if color == "" {
|
||
color = "#4285f4"
|
||
}
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`INSERT INTO cal_calendars (owner_id, name, color, description, path_segment)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||
ownerID, name, color, description, segment,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||
if isUniqueViolation(err) {
|
||
return nil, ErrConflict
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("insert calendar at %q: %w", segment, err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
func isUniqueViolation(err error) bool {
|
||
var pgErr *pgconn.PgError
|
||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||
}
|
||
|
||
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
|
||
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
|
||
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
|
||
// CalDAV-Client kann das.
|
||
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
|
||
cals, err := s.ListCalendars(ctx, ownerID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(cals) > 0 {
|
||
return &cals[0], nil
|
||
}
|
||
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
|
||
}
|
||
|
||
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
|
||
if color == "" {
|
||
color = "#4285f4"
|
||
}
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`INSERT INTO cal_calendars (owner_id, name, color, description)
|
||
VALUES ($1, $2, $3, $4)
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||
ownerID, name, color, description,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
// UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt
|
||
// die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld
|
||
// schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu
|
||
// laden.
|
||
func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) {
|
||
var c Calendar
|
||
err := s.db.QueryRow(ctx,
|
||
`UPDATE cal_calendars SET
|
||
name = COALESCE($3, name),
|
||
color = COALESCE($4, color),
|
||
description = COALESCE($5, description),
|
||
timezone = COALESCE($6, timezone)
|
||
WHERE owner_id = $1 AND id = $2
|
||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`,
|
||
ownerID, id, name, color, description, timezone,
|
||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("update calendar: %w", err)
|
||
}
|
||
return &c, nil
|
||
}
|
||
|
||
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
|
||
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
|
||
if err != nil {
|
||
return fmt.Errorf("delete calendar: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||
FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`,
|
||
calendarID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query objects: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Object
|
||
for rows.Next() {
|
||
o, err := scanObject(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, o)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
|
||
row := s.db.QueryRow(ctx,
|
||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
|
||
calendarID, uid,
|
||
)
|
||
o, err := scanObject(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
func scanObject(row rowScanner) (Object, error) {
|
||
var o Object
|
||
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||
if err != nil {
|
||
return Object{}, fmt.Errorf("scan object: %w", err)
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
|
||
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
|
||
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
|
||
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
// Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den
|
||
// Stand tragen, auf den der Kalender damit springt.
|
||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var o Object
|
||
err = tx.QueryRow(ctx,
|
||
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
|
||
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend,
|
||
sync_seq = EXCLUDED.sync_seq, updated_at = now()
|
||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||
calendarID, uid, component, data, etag, dtstart, dtend, seq,
|
||
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("upsert object: %w", err)
|
||
}
|
||
|
||
// Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein
|
||
// Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten
|
||
// Abgleich beides zu sehen -- die Datei und ihre Todesmeldung.
|
||
if _, err := tx.Exec(ctx,
|
||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, fmt.Errorf("commit tx: %w", err)
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
// MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der
|
||
// gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die
|
||
// Sammlung, so wie ein MOVE in WebDAV.
|
||
//
|
||
// Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn
|
||
// in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen
|
||
// **beide** Kalender ein neues ctag – mit nur einem holte der Client die
|
||
// andere Seite erst beim nächsten Vollabgleich, und der Termin stünde
|
||
// zwischenzeitlich doppelt oder gar nicht im Telefon.
|
||
func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) {
|
||
if fromCal == toCal {
|
||
return s.GetObjectByUID(ctx, fromCal, uid)
|
||
}
|
||
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
var taken bool
|
||
if err := tx.QueryRow(ctx,
|
||
`SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`,
|
||
toCal, uid,
|
||
).Scan(&taken); err != nil {
|
||
return nil, fmt.Errorf("check target: %w", err)
|
||
}
|
||
if taken {
|
||
return nil, ErrConflict
|
||
}
|
||
|
||
// Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin
|
||
// fort (Grabstein), im Ziel ist er neu.
|
||
fromSeq, err := bumpSeq(ctx, tx, fromCal)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
toSeq, err := bumpSeq(ctx, tx, toCal)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
row := tx.QueryRow(ctx,
|
||
`UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now()
|
||
WHERE calendar_id = $3 AND uid = $4
|
||
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
|
||
toCal, toSeq, fromCal, uid,
|
||
)
|
||
o, err := scanObject(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil {
|
||
return nil, err
|
||
}
|
||
// Im Ziel darf kein alter Grabstein derselben UID stehen bleiben.
|
||
if _, err := tx.Exec(ctx,
|
||
`DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("clear tombstone: %w", err)
|
||
}
|
||
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return nil, fmt.Errorf("commit tx: %w", err)
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error {
|
||
tx, err := s.db.Begin(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback(ctx)
|
||
|
||
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
|
||
if err != nil {
|
||
return fmt.Errorf("delete object: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
seq, err := bumpSeq(ctx, tx, calendarID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil {
|
||
return err
|
||
}
|
||
if err := pruneTombstones(ctx, tx, calendarID); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit(ctx)
|
||
}
|
||
|
||
// ── Abgleich nur der Änderungen (RFC 6578) ───────────────────────────────────
|
||
|
||
// Wie lange ein Grabstein stehen bleibt.
|
||
//
|
||
// Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann
|
||
// ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen;
|
||
// danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer.
|
||
// Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine
|
||
// Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät.
|
||
const tombstoneTTL = 180 * 24 * time.Hour
|
||
|
||
type execer interface {
|
||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||
}
|
||
|
||
// bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen.
|
||
//
|
||
// Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen
|
||
// abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über
|
||
// denselben Sachverhalt, und zwei Wahrheiten laufen auseinander.
|
||
func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) {
|
||
var seq int64
|
||
if err := tx.QueryRow(ctx,
|
||
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID,
|
||
).Scan(&seq); err != nil {
|
||
return 0, fmt.Errorf("bump ctag: %w", err)
|
||
}
|
||
return seq, nil
|
||
}
|
||
|
||
func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error {
|
||
_, err := tx.Exec(ctx,
|
||
`INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at)
|
||
VALUES ($1, $2, $3, now())
|
||
ON CONFLICT (calendar_id, uid) DO UPDATE
|
||
SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`,
|
||
calendarID, uid, seq)
|
||
if err != nil {
|
||
return fmt.Errorf("set tombstone: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an.
|
||
//
|
||
// Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis
|
||
// wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die
|
||
// Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort,
|
||
// in der eine Löschung einfach fehlt, und behielte den Termin für immer.
|
||
//
|
||
// Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen
|
||
// Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt.
|
||
func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error {
|
||
// Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der
|
||
// ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die
|
||
// erste Zeile greift, setzt den Boden auf irgendeinen der geräumten
|
||
// Stände – und ein Client mit einem Token dazwischen bekäme dann eine
|
||
// Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele.
|
||
var hoechste *int64
|
||
if err := tx.QueryRow(ctx,
|
||
`WITH geraeumt AS (
|
||
DELETE FROM cal_tombstones
|
||
WHERE calendar_id = $1 AND deleted_at < now() - $2::interval
|
||
RETURNING sync_seq
|
||
)
|
||
SELECT max(sync_seq) FROM geraeumt`,
|
||
calendarID, tombstoneTTL.String(),
|
||
).Scan(&hoechste); err != nil {
|
||
return fmt.Errorf("prune tombstones: %w", err)
|
||
}
|
||
if hoechste == nil {
|
||
return nil
|
||
}
|
||
if _, err := tx.Exec(ctx,
|
||
`UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`,
|
||
calendarID, *hoechste,
|
||
); err != nil {
|
||
return fmt.Errorf("raise sync floor: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Changes ist die Antwort auf „was hat sich seit Stand N getan".
|
||
type Changes struct {
|
||
// Angelegt oder geändert – beides sieht für den Client gleich aus, er
|
||
// holt das Objekt in beiden Fällen neu.
|
||
Changed []Object
|
||
// UIDs, die es nicht mehr gibt.
|
||
Deleted []string
|
||
// Der Stand, auf dem der Client danach steht.
|
||
Token int64
|
||
}
|
||
|
||
// ChangesSince liefert alles, was sich in einem Kalender seit `since` getan
|
||
// hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen
|
||
// Bestand – genau das, was ein Client beim ersten Abgleich braucht.
|
||
//
|
||
// Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind
|
||
// Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre
|
||
// unvollständig, ohne dass es jemandem auffiele.
|
||
func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) {
|
||
if since < 0 || since > cal.CTag {
|
||
// Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von
|
||
// einer anderen Instanz oder aus einer wiederhergestellten Sicherung.
|
||
return nil, false, nil
|
||
}
|
||
if since > 0 && since < cal.SyncFloor {
|
||
return nil, false, nil
|
||
}
|
||
|
||
out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}}
|
||
|
||
rows, err := s.db.Query(ctx,
|
||
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
|
||
FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||
cal.ID, since)
|
||
if err != nil {
|
||
return nil, false, fmt.Errorf("query changed objects: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
o, err := scanObject(rows)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
out.Changed = append(out.Changed, o)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, false, err
|
||
}
|
||
|
||
// Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt:
|
||
// Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm
|
||
// nur Löschungen für Termine schicken, die er nie gesehen hat.
|
||
if since == 0 {
|
||
return out, true, nil
|
||
}
|
||
|
||
trows, err := s.db.Query(ctx,
|
||
`SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`,
|
||
cal.ID, since)
|
||
if err != nil {
|
||
return nil, false, fmt.Errorf("query tombstones: %w", err)
|
||
}
|
||
defer trows.Close()
|
||
for trows.Next() {
|
||
var uid string
|
||
if err := trows.Scan(&uid); err != nil {
|
||
return nil, false, err
|
||
}
|
||
out.Deleted = append(out.Deleted, uid)
|
||
}
|
||
return out, true, trows.Err()
|
||
}
|