♻️ Bootstrap when syncmarker is zero
This commit is contained in:
@@ -11,10 +11,9 @@ import (
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// ErrSyncStale is returned when the client's since_version is behind the prune marker.
|
||||
// ErrSyncStale is returned when an existing client is behind the prune marker.
|
||||
var ErrSyncStale = errors.New("sync state stale: full re-sync required")
|
||||
|
||||
// pruneEntity and pruneOp are sentinel values written as a prune marker row.
|
||||
const pruneEntity = "_pruned"
|
||||
const pruneOp = "marker"
|
||||
|
||||
@@ -30,15 +29,79 @@ func NewSyncStore(db *sql.DB) *SyncStore {
|
||||
type SyncChange struct {
|
||||
Entity string `json:"entity"`
|
||||
EntityID string `json:"entity_id"`
|
||||
Op string `json:"op"` // "upsert" | "delete" | "marker"
|
||||
Op string `json:"op"`
|
||||
Version int64 `json:"version"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// Bootstrap returns the current contents of every client-replicated entity.
|
||||
// It deliberately bypasses sync_log so retention cannot hide old data.
|
||||
func (s *SyncStore) Bootstrap(ctx context.Context) ([]SyncChange, int64, error) {
|
||||
var changes []SyncChange
|
||||
add := func(entity, id string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes = append(changes, SyncChange{Entity: entity, EntityID: id, Op: "upsert", Payload: string(payload)})
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE deleted_at IS NULL`)
|
||||
if err != nil { return nil, 0, err }
|
||||
for rows.Next() {
|
||||
var e domain.Entry; var end, deleted sql.NullInt64; var stopped int
|
||||
if err := rows.Scan(&e.ID, &e.StartTime, &end, &stopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deleted); err != nil { rows.Close(); return nil, 0, err }
|
||||
if end.Valid { e.EndTime = &end.Int64 }; e.AutoStopped = stopped != 0
|
||||
if err := add("entries", e.ID, e); err != nil { rows.Close(); return nil, 0, err }
|
||||
}
|
||||
if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at FROM closed_days`)
|
||||
if err != nil { return nil, 0, err }
|
||||
for rows.Next() {
|
||||
var d domain.ClosedDay; var start, end sql.NullInt64
|
||||
if err := rows.Scan(&d.DayKey, &start, &end, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt); err != nil { rows.Close(); return nil, 0, err }
|
||||
if start.Valid { d.StartTime = &start.Int64 }; if end.Valid { d.EndTime = &end.Int64 }
|
||||
if err := add("closed_days", d.DayKey, d); err != nil { rows.Close(); return nil, 0, err }
|
||||
}
|
||||
if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at FROM closed_weeks`)
|
||||
if err != nil { return nil, 0, err }
|
||||
for rows.Next() {
|
||||
var w domain.ClosedWeek
|
||||
if err := rows.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt); err != nil { rows.Close(); return nil, 0, err }
|
||||
if err := add("closed_weeks", w.WeekKey, w); err != nil { rows.Close(); return nil, 0, err }
|
||||
}
|
||||
if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at, updated_at FROM settings_history`)
|
||||
if err != nil { return nil, 0, err }
|
||||
for rows.Next() {
|
||||
var set domain.Settings
|
||||
if err := rows.Scan(&set.ID, &set.EffectiveFrom, &set.HoursPerWeek, &set.WorkdaysMask, &set.Timezone, &set.CreatedAt, &set.UpdatedAt); err != nil { rows.Close(); return nil, 0, err }
|
||||
if err := add("settings_history", set.ID, set); err != nil { rows.Close(); return nil, 0, err }
|
||||
}
|
||||
if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT id, delta_ms, note, effective_at, created_at, updated_at FROM balance_adjustments`)
|
||||
if err != nil { return nil, 0, err }
|
||||
for rows.Next() {
|
||||
var a domain.BalanceAdjustment
|
||||
if err := rows.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt); err != nil { rows.Close(); return nil, 0, err }
|
||||
if err := add("balance_adjustments", a.ID, a); err != nil { rows.Close(); return nil, 0, err }
|
||||
}
|
||||
if err := rows.Err(); err != nil { rows.Close(); return nil, 0, err }; rows.Close()
|
||||
|
||||
var version int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM sync_log`).Scan(&version); err != nil { return nil, 0, err }
|
||||
return changes, version, nil
|
||||
}
|
||||
|
||||
// Pull returns all sync_log rows with version > sinceVersion.
|
||||
// It calls Prune first with a 30-day TTL. A zero-version pull is a bootstrap
|
||||
// and may proceed past the prune marker; non-zero clients behind the marker
|
||||
// must perform a full re-sync.
|
||||
// It calls Prune first with a 30-day TTL. Clients behind a prune marker
|
||||
// must bootstrap from current entities before resuming incremental sync.
|
||||
func (s *SyncStore) Pull(ctx context.Context, sinceVersion int64) ([]SyncChange, int64, error) {
|
||||
if err := s.Prune(ctx, 30*24*time.Hour); err != nil {
|
||||
return nil, 0, err
|
||||
@@ -60,10 +123,7 @@ func (s *SyncStore) Pull(ctx context.Context, sinceVersion int64) ([]SyncChange,
|
||||
return nil, 0, err
|
||||
}
|
||||
if c.Entity == pruneEntity {
|
||||
if sinceVersion != 0 {
|
||||
return nil, 0, ErrSyncStale
|
||||
}
|
||||
continue
|
||||
return nil, 0, ErrSyncStale
|
||||
}
|
||||
if c.Version > maxVersion {
|
||||
maxVersion = c.Version
|
||||
|
||||
Reference in New Issue
Block a user