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)
syncStore := store.NewSyncStore(db)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
settingsSvc := service.NewSettingsService(settingsStore)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
settingsSvc := service.NewSettingsService(settingsStore, syncStore)
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
// Background goroutine: auto-stop entries that cross midnight

View File

@@ -22,6 +22,7 @@ type DayService struct {
closedDays *store.ClosedDayStore
closedWeeks *store.ClosedWeekStore
settings *store.SettingsStore
syncStore *store.SyncStore
tz *time.Location
}
@@ -30,6 +31,7 @@ func NewDayService(
closedDays *store.ClosedDayStore,
closedWeeks *store.ClosedWeekStore,
settings *store.SettingsStore,
syncStore *store.SyncStore,
tz *time.Location,
) *DayService {
return &DayService{
@@ -37,6 +39,7 @@ func NewDayService(
closedDays: closedDays,
closedWeeks: closedWeeks,
settings: settings,
syncStore: syncStore,
tz: tz,
}
}
@@ -74,7 +77,11 @@ func (s *DayService) recomputeWeek(ctx context.Context, dayKey string) error {
cw.WorkedMs = totalWorkedMs
cw.DeltaMs = totalWorkedMs - cw.ExpectedMs
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.
@@ -136,6 +143,7 @@ func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.Close
if err := s.closedDays.Upsert(ctx, cd); err != nil {
return nil, err
}
_ = s.syncStore.LogClosedDay(ctx, cd)
if err := s.recomputeWeek(ctx, dayKey); err != nil {
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 {
return nil, err
}
_ = s.syncStore.LogClosedDay(ctx, cd)
if err := s.recomputeWeek(ctx, dayKey); err != nil {
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 {
return err
}
_ = s.syncStore.LogClosedDayDelete(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)
closedWeekStore := store.NewClosedWeekStore(db)
settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db)
tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
settingsSvc := service.NewSettingsService(settingsStore)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
settingsSvc := service.NewSettingsService(settingsStore, syncStore)
return entrySvc, daySvc, settingsSvc
}

View File

@@ -26,22 +26,25 @@ var (
// EntryService handles business logic for time entries.
type EntryService struct {
entries *store.EntryStore
closedDays *store.ClosedDayStore
settings *store.SettingsStore
tz *time.Location
entries *store.EntryStore
closedDays *store.ClosedDayStore
settings *store.SettingsStore
syncStore *store.SyncStore
tz *time.Location
}
func NewEntryService(
entries *store.EntryStore,
closedDays *store.ClosedDayStore,
settings *store.SettingsStore,
syncStore *store.SyncStore,
tz *time.Location,
) *EntryService {
return &EntryService{
entries: entries,
closedDays: closedDays,
settings: settings,
syncStore: syncStore,
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 {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
@@ -132,7 +136,6 @@ func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopp
startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(endMs)
if endDayKey != startDayKey {
// Cross-midnight: cap at end of start day
endMs = s.midnightEndMs(e.StartTime)
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 {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
@@ -188,7 +192,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
if input.StartTime != nil {
newDayKey := s.dayKeyForMs(*input.StartTime)
// Reject if the new start_time moves the entry into the future.
if newDayKey > s.dayKeyForMs(s.nowMs()) {
return nil, ErrFutureDay
}
@@ -196,7 +199,6 @@ func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryI
e.DayKey = newDayKey
}
if input.EndTime != nil {
// Validate same-day
startDayKey := s.dayKeyForMs(e.StartTime)
endDayKey := s.dayKeyForMs(*input.EndTime)
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 {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
@@ -226,7 +229,6 @@ type CreateIntervalInput struct {
}
// 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) {
if input.EndTime <= input.StartTime {
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
}
// Reject future intervals.
todayKey := s.dayKeyForMs(s.nowMs())
if startDayKey > todayKey {
return nil, ErrFutureDay
}
// Check day is not closed.
closed, err := s.closedDays.GetByDayKey(ctx, startDayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
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 {
return nil, err
}
_ = s.syncStore.LogEntry(ctx, e)
return e, nil
}
// Delete soft-deletes an entry.
func (s *EntryService) Delete(ctx context.Context, id string) error {
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.
// Called by the midnight background goroutine.
func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) {
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)
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) {
@@ -210,9 +210,10 @@ func TestUpdateRejectsClosedDay(t *testing.T) {
closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db)
settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db)
tz, _ := time.LoadLocation("UTC")
svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
svc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, tz)
entry, _ := svc.Start(ctx, "")
svc.Stop(ctx)

View File

@@ -22,11 +22,12 @@ var (
// SettingsService manages settings with effective-from history.
type SettingsService struct {
store *store.SettingsStore
store *store.SettingsStore
syncStore *store.SyncStore
}
func NewSettingsService(s *store.SettingsStore) *SettingsService {
return &SettingsService{store: s}
func NewSettingsService(s *store.SettingsStore, syncStore *store.SyncStore) *SettingsService {
return &SettingsService{store: s, syncStore: syncStore}
}
// 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 {
return nil, err
}
_ = s.syncStore.LogSettings(ctx, set)
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 {
return nil, err
}
_ = s.syncStore.LogSettings(ctx, set)
return set, nil
}
@@ -157,5 +160,9 @@ func (s *SettingsService) DeleteSettings(ctx context.Context, id string) error {
}
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 {
return nil, err
}
_ = s.syncStore.LogClosedWeek(ctx, cw)
return cw, nil
}
@@ -254,7 +255,11 @@ func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error {
if existing == nil {
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.

View File

@@ -27,10 +27,10 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, syncStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, syncStore, 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
}

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))
}
// 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.
func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) error {
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))
}
// 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.
func (s *SyncStore) LogSettings(ctx context.Context, set *domain.Settings) error {
payload, err := json.Marshal(set)