Files
Aether/backend/internal/calendar/store.go
T
marcandClaude Opus 5 8fdde182b7 feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)
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 &#34;. 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>
2026-09-12 00:03:08 +02:00

542 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}