M9.1: wire sync logging into all mutation paths

- Add LogClosedDayDelete and LogClosedWeekDelete to SyncStore
- Inject syncStore into EntryService; log Start, Stop, StopByID,
  Update, CreateInterval, Delete, AutoStopStalledEntries
- Inject syncStore into DayService; log CloseDay, MarkDay, ReopenDay,
  and the recomputeWeek closed-week upsert
- Inject syncStore into SettingsService; log Upsert, UpdateSettings,
  DeleteSettings
- Add LogClosedWeek/LogClosedWeekDelete calls in WeekService.CloseWeek
  and ReopenWeek
- Update main.go and all service test helpers for new constructor signatures
- All Go tests and 19 Vitest tests pass
This commit is contained in:
2026-04-30 22:57:02 +02:00
parent d8366f5c25
commit a8a4ea0d4f
9 changed files with 82 additions and 30 deletions

View File

@@ -46,9 +46,9 @@ func main() {
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db) syncStore := store.NewSyncStore(db)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
settingsSvc := service.NewSettingsService(settingsStore) settingsSvc := service.NewSettingsService(settingsStore, syncStore)
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
// Background goroutine: auto-stop entries that cross midnight // Background goroutine: auto-stop entries that cross midnight

View File

@@ -22,6 +22,7 @@ type DayService struct {
closedDays *store.ClosedDayStore closedDays *store.ClosedDayStore
closedWeeks *store.ClosedWeekStore closedWeeks *store.ClosedWeekStore
settings *store.SettingsStore settings *store.SettingsStore
syncStore *store.SyncStore
tz *time.Location tz *time.Location
} }
@@ -30,6 +31,7 @@ func NewDayService(
closedDays *store.ClosedDayStore, closedDays *store.ClosedDayStore,
closedWeeks *store.ClosedWeekStore, closedWeeks *store.ClosedWeekStore,
settings *store.SettingsStore, settings *store.SettingsStore,
syncStore *store.SyncStore,
tz *time.Location, tz *time.Location,
) *DayService { ) *DayService {
return &DayService{ return &DayService{
@@ -37,6 +39,7 @@ func NewDayService(
closedDays: closedDays, closedDays: closedDays,
closedWeeks: closedWeeks, closedWeeks: closedWeeks,
settings: settings, settings: settings,
syncStore: syncStore,
tz: tz, tz: tz,
} }
} }
@@ -74,7 +77,11 @@ func (s *DayService) recomputeWeek(ctx context.Context, dayKey string) error {
cw.WorkedMs = totalWorkedMs cw.WorkedMs = totalWorkedMs
cw.DeltaMs = totalWorkedMs - cw.ExpectedMs cw.DeltaMs = totalWorkedMs - cw.ExpectedMs
cw.UpdatedAt = time.Now().UnixMilli() cw.UpdatedAt = time.Now().UnixMilli()
return s.closedWeeks.Upsert(ctx, cw) if err := s.closedWeeks.Upsert(ctx, cw); err != nil {
return err
}
_ = s.syncStore.LogClosedWeek(ctx, cw)
return nil
} }
// CloseDay merges all completed entries for the given day key into a ClosedDay. // CloseDay merges all completed entries for the given day key into a ClosedDay.
@@ -136,6 +143,7 @@ func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.Close
if err := s.closedDays.Upsert(ctx, cd); err != nil { if err := s.closedDays.Upsert(ctx, cd); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogClosedDay(ctx, cd)
if err := s.recomputeWeek(ctx, dayKey); err != nil { if err := s.recomputeWeek(ctx, dayKey); err != nil {
return nil, err return nil, err
} }
@@ -175,6 +183,7 @@ func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.Day
if err := s.closedDays.Upsert(ctx, cd); err != nil { if err := s.closedDays.Upsert(ctx, cd); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogClosedDay(ctx, cd)
if err := s.recomputeWeek(ctx, dayKey); err != nil { if err := s.recomputeWeek(ctx, dayKey); err != nil {
return nil, err return nil, err
} }
@@ -196,6 +205,7 @@ func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error {
if err := s.closedDays.Delete(ctx, dayKey); err != nil { if err := s.closedDays.Delete(ctx, dayKey); err != nil {
return err return err
} }
_ = s.syncStore.LogClosedDayDelete(ctx, dayKey)
return s.recomputeWeek(ctx, dayKey) return s.recomputeWeek(ctx, dayKey)
} }

View File

@@ -22,11 +22,12 @@ func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayServic
closedDayStore := store.NewClosedDayStore(db) closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db) closedWeekStore := store.NewClosedWeekStore(db)
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db)
tz, _ := time.LoadLocation("UTC") tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
settingsSvc := service.NewSettingsService(settingsStore) settingsSvc := service.NewSettingsService(settingsStore, syncStore)
return entrySvc, daySvc, settingsSvc return entrySvc, daySvc, settingsSvc
} }

View File

@@ -26,22 +26,25 @@ var (
// EntryService handles business logic for time entries. // EntryService handles business logic for time entries.
type EntryService struct { type EntryService struct {
entries *store.EntryStore entries *store.EntryStore
closedDays *store.ClosedDayStore closedDays *store.ClosedDayStore
settings *store.SettingsStore settings *store.SettingsStore
tz *time.Location syncStore *store.SyncStore
tz *time.Location
} }
func NewEntryService( func NewEntryService(
entries *store.EntryStore, entries *store.EntryStore,
closedDays *store.ClosedDayStore, closedDays *store.ClosedDayStore,
settings *store.SettingsStore, settings *store.SettingsStore,
syncStore *store.SyncStore,
tz *time.Location, tz *time.Location,
) *EntryService { ) *EntryService {
return &EntryService{ return &EntryService{
entries: entries, entries: entries,
closedDays: closedDays, closedDays: closedDays,
settings: settings, settings: settings,
syncStore: syncStore,
tz: tz, tz: tz,
} }
} }
@@ -94,6 +97,7 @@ func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, e
if err := s.entries.Create(ctx, e); err != nil { if err := s.entries.Create(ctx, e); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogEntry(ctx, e)
return e, nil return e, nil
} }
@@ -132,7 +136,6 @@ func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopp
startDayKey := s.dayKeyForMs(e.StartTime) startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(endMs) endDayKey := s.dayKeyForMs(endMs)
if endDayKey != startDayKey { if endDayKey != startDayKey {
// Cross-midnight: cap at end of start day
endMs = s.midnightEndMs(e.StartTime) endMs = s.midnightEndMs(e.StartTime)
autoStopped = true autoStopped = true
} }
@@ -144,6 +147,7 @@ func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopp
if err := s.entries.Update(ctx, e); err != nil { if err := s.entries.Update(ctx, e); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogEntry(ctx, e)
return e, nil return e, nil
} }
@@ -188,7 +192,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
if input.StartTime != nil { if input.StartTime != nil {
newDayKey := s.dayKeyForMs(*input.StartTime) newDayKey := s.dayKeyForMs(*input.StartTime)
// Reject if the new start_time moves the entry into the future.
if newDayKey > s.dayKeyForMs(s.nowMs()) { if newDayKey > s.dayKeyForMs(s.nowMs()) {
return nil, ErrFutureDay return nil, ErrFutureDay
} }
@@ -196,7 +199,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
e.DayKey = newDayKey e.DayKey = newDayKey
} }
if input.EndTime != nil { if input.EndTime != nil {
// Validate same-day
startDayKey := s.dayKeyForMs(e.StartTime) startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(*input.EndTime) endDayKey := s.dayKeyForMs(*input.EndTime)
if startDayKey != endDayKey { if startDayKey != endDayKey {
@@ -215,6 +217,7 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
if err := s.entries.Update(ctx, e); err != nil { if err := s.entries.Update(ctx, e); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogEntry(ctx, e)
return e, nil return e, nil
} }
@@ -226,7 +229,6 @@ type CreateIntervalInput struct {
} }
// CreateInterval adds a completed interval with explicit start and end times. // CreateInterval adds a completed interval with explicit start and end times.
// Rules: same-day, end > start, day not closed, day not in the future.
func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) { func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalInput) (*domain.Entry, error) {
if input.EndTime <= input.StartTime { if input.EndTime <= input.StartTime {
return nil, fmt.Errorf("end_time must be after start_time") return nil, fmt.Errorf("end_time must be after start_time")
@@ -238,13 +240,11 @@ func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalI
return nil, ErrCrossesMidnight return nil, ErrCrossesMidnight
} }
// Reject future intervals.
todayKey := s.dayKeyForMs(s.nowMs()) todayKey := s.dayKeyForMs(s.nowMs())
if startDayKey > todayKey { if startDayKey > todayKey {
return nil, ErrFutureDay return nil, ErrFutureDay
} }
// Check day is not closed.
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey) closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err return nil, err
@@ -265,18 +265,34 @@ func (s *EntryService) CreateInterval(ctx context.Context, input CreateIntervalI
if err := s.entries.Create(ctx, e); err != nil { if err := s.entries.Create(ctx, e); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogEntry(ctx, e)
return e, nil return e, nil
} }
// Delete soft-deletes an entry. // Delete soft-deletes an entry.
func (s *EntryService) Delete(ctx context.Context, id string) error { func (s *EntryService) Delete(ctx context.Context, id string) error {
nowMs := s.nowMs() nowMs := s.nowMs()
return s.entries.SoftDelete(ctx, id, nowMs) if err := s.entries.SoftDelete(ctx, id, nowMs); err != nil {
return err
}
_ = s.syncStore.LogEntryDelete(ctx, id)
return nil
} }
// AutoStopStalledEntries stops any running entries whose day_key is before today. // AutoStopStalledEntries stops any running entries whose day_key is before today.
// Called by the midnight background goroutine. // Called by the midnight background goroutine.
func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) { func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) {
today := s.dayKeyForMs(s.nowMs()) today := s.dayKeyForMs(s.nowMs())
return s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs()) ids, err := s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs())
if err != nil {
return nil, err
}
// Log each auto-stopped entry to sync.
for _, id := range ids {
e, err := s.entries.GetByID(ctx, id)
if err == nil {
_ = s.syncStore.LogEntry(ctx, e)
}
}
return ids, nil
} }

View File

@@ -23,7 +23,7 @@ func newTestServices(t *testing.T) *service.EntryService {
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC") tz, _ := time.LoadLocation("UTC")
return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) return service.NewEntryService(entryStore, closedDayStore, settingsStore, store.NewSyncStore(db), tz)
} }
func TestStartStop(t *testing.T) { func TestStartStop(t *testing.T) {
@@ -210,9 +210,10 @@ func TestUpdateRejectsClosedDay(t *testing.T) {
closedDayStore := store.NewClosedDayStore(db) closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db) closedWeekStore := store.NewClosedWeekStore(db)
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db)
tz, _ := time.LoadLocation("UTC") tz, _ := time.LoadLocation("UTC")
svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
entry, _ := svc.Start(ctx, "") entry, _ := svc.Start(ctx, "")
svc.Stop(ctx) svc.Stop(ctx)

View File

@@ -22,11 +22,12 @@ var (
// SettingsService manages settings with effective-from history. // SettingsService manages settings with effective-from history.
type SettingsService struct { type SettingsService struct {
store *store.SettingsStore store *store.SettingsStore
syncStore *store.SyncStore
} }
func NewSettingsService(s *store.SettingsStore) *SettingsService { func NewSettingsService(s *store.SettingsStore, syncStore *store.SyncStore) *SettingsService {
return &SettingsService{store: s} return &SettingsService{store: s, syncStore: syncStore}
} }
// Current returns settings effective as of today. // Current returns settings effective as of today.
@@ -92,6 +93,7 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput)
if err := s.store.Insert(ctx, set); err != nil { if err := s.store.Insert(ctx, set); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogSettings(ctx, set)
return set, nil return set, nil
} }
@@ -138,6 +140,7 @@ func (s *SettingsService) UpdateSettings(ctx context.Context, id string, input U
if err := s.store.Update(ctx, set); err != nil { if err := s.store.Update(ctx, set); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogSettings(ctx, set)
return set, nil return set, nil
} }
@@ -157,5 +160,9 @@ func (s *SettingsService) DeleteSettings(ctx context.Context, id string) error {
} }
return err return err
} }
return s.store.Delete(ctx, id) if err := s.store.Delete(ctx, id); err != nil {
return err
}
_ = s.syncStore.LogSettingsDelete(ctx, id)
return nil
} }

View File

@@ -238,6 +238,7 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl
if err := s.closedWeeks.Upsert(ctx, cw); err != nil { if err := s.closedWeeks.Upsert(ctx, cw); err != nil {
return nil, err return nil, err
} }
_ = s.syncStore.LogClosedWeek(ctx, cw)
return cw, nil return cw, nil
} }
@@ -254,7 +255,11 @@ func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error {
if existing == nil { if existing == nil {
return ErrWeekNotClosed return ErrWeekNotClosed
} }
return s.closedWeeks.Delete(ctx, weekKey) if err := s.closedWeeks.Delete(ctx, weekKey); err != nil {
return err
}
_ = s.syncStore.LogClosedWeekDelete(ctx, weekKey)
return nil
} }
// ListWeeks returns closed weeks within a range. // ListWeeks returns closed weeks within a range.

View File

@@ -27,10 +27,10 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC") tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
settingsSvc := service.NewSettingsService(settingsStore) settingsSvc := service.NewSettingsService(settingsStore, syncStore)
return entrySvc, daySvc, weekSvc, settingsSvc return entrySvc, daySvc, weekSvc, settingsSvc
} }

View File

@@ -150,6 +150,12 @@ func (s *SyncStore) LogClosedDay(ctx context.Context, d *domain.ClosedDay) error
return s.log(ctx, "closed_days", d.DayKey, "upsert", string(payload)) return s.log(ctx, "closed_days", d.DayKey, "upsert", string(payload))
} }
// LogClosedDayDelete appends a closed_day delete to the sync log.
func (s *SyncStore) LogClosedDayDelete(ctx context.Context, dayKey string) error {
payload := fmt.Sprintf(`{"day_key":%q}`, dayKey)
return s.log(ctx, "closed_days", dayKey, "delete", payload)
}
// LogClosedWeek appends a closed_week upsert to the sync log. // LogClosedWeek appends a closed_week upsert to the sync log.
func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) error { func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) error {
payload, err := json.Marshal(w) payload, err := json.Marshal(w)
@@ -159,6 +165,12 @@ func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) err
return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload)) return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload))
} }
// LogClosedWeekDelete appends a closed_week delete to the sync log.
func (s *SyncStore) LogClosedWeekDelete(ctx context.Context, weekKey string) error {
payload := fmt.Sprintf(`{"week_key":%q}`, weekKey)
return s.log(ctx, "closed_weeks", weekKey, "delete", payload)
}
// LogSettings appends a settings upsert to the sync log. // LogSettings appends a settings upsert to the sync log.
func (s *SyncStore) LogSettings(ctx context.Context, set *domain.Settings) error { func (s *SyncStore) LogSettings(ctx context.Context, set *domain.Settings) error {
payload, err := json.Marshal(set) payload, err := json.Marshal(set)