From 80f2431867935a65c805532f8bc95a13662d26ed Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 29 Oct 2025 15:51:20 -0600 Subject: [PATCH] feat(core): add data layer for sessions Introduces event, goal, and task storage subsystems built on Badger: - event: append-only log with typed payloads for state changes - goal: CRUD operations for session goals with event tracking - task: full lifecycle management with status indexing - testutil: helpers for deterministic test clocks and databases - timeutil: clock abstraction for testability All packages include comprehensive test coverage. Co-authored-by: Crush --- internal/event/event.go | 311 +++++++++++++++++++++++++ internal/event/event_test.go | 151 ++++++++++++ internal/event/payloads.go | 144 ++++++++++++ internal/goal/goal.go | 162 +++++++++++++ internal/goal/goal_test.go | 84 +++++++ internal/task/store.go | 427 ++++++++++++++++++++++++++++++++++ internal/task/store_test.go | 211 +++++++++++++++++ internal/task/task.go | 104 +++++++++ internal/testutil/clock.go | 31 +++ internal/testutil/db.go | 31 +++ internal/timeutil/timeutil.go | 42 ++++ 11 files changed, 1698 insertions(+) create mode 100644 internal/event/event.go create mode 100644 internal/event/event_test.go create mode 100644 internal/event/payloads.go create mode 100644 internal/goal/goal.go create mode 100644 internal/goal/goal_test.go create mode 100644 internal/task/store.go create mode 100644 internal/task/store_test.go create mode 100644 internal/task/task.go create mode 100644 internal/testutil/clock.go create mode 100644 internal/testutil/db.go create mode 100644 internal/timeutil/timeutil.go diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000000000000000000000000000000000000..5271cbbec86d345f63678a952b2a8fa113c094d4 --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,311 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package event + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/timeutil" +) + +// ErrInvalidType indicates that an event type was not recognised. +var ErrInvalidType = errors.New("event: invalid type") + +// ErrEmptyCommand indicates that a command string was not provided. +var ErrEmptyCommand = errors.New("event: command is required") + +// Type enumerates known event kinds. +type Type string + +const ( + TypeGoalSet Type = "goal_set" + TypeGoalUpdated Type = "goal_updated" + TypeTaskAdded Type = "task_added" + TypeTaskUpdated Type = "task_updated" + TypeTaskStatusChange Type = "task_status_changed" +) + +// Valid reports whether t is a recognised event type. +func (t Type) Valid() bool { + switch t { + case TypeGoalSet, + TypeGoalUpdated, + TypeTaskAdded, + TypeTaskUpdated, + TypeTaskStatusChange: + return true + default: + return false + } +} + +// Record represents a stored event entry. +type Record struct { + Seq uint64 `json:"seq"` + At time.Time `json:"at"` + Type Type `json:"type"` + Reason *string `json:"reason,omitempty"` + Command string `json:"cmd"` + Payload json.RawMessage `json:"payload"` +} + +// HasReason reports whether a reason was supplied. +func (r Record) HasReason() bool { + return r.Reason != nil && *r.Reason != "" +} + +// UnmarshalPayload decodes the payload into dst. +func (r Record) UnmarshalPayload(dst any) error { + if len(r.Payload) == 0 { + return errors.New("event: payload is empty") + } + return json.Unmarshal(r.Payload, dst) +} + +// AppendInput captures the data necessary to append an event. +type AppendInput struct { + Type Type + Command string + Reason string + Payload any + At time.Time +} + +// ListOptions controls event listing. +type ListOptions struct { + // After skips events with seq <= After. + After uint64 + // Limit restricts the number of events returned. Zero or negative returns all. + Limit int +} + +// Store provides high-level helpers for working with events. +type Store struct { + db *db.Database + clock timeutil.Clock +} + +// NewStore constructs a Store. When clock is nil, a UTC system clock is used. +func NewStore(database *db.Database, clock timeutil.Clock) *Store { + if clock == nil { + clock = timeutil.UTCClock{} + } + return &Store{ + db: database, + clock: clock, + } +} + +// WithTxn exposes transactional helpers for use within db.Update. +func (s *Store) WithTxn(txn *db.Txn) TxnStore { + return TxnStore{ + txn: txn, + clock: s.clock, + } +} + +// Append records an event for sid. +func (s *Store) Append(ctx context.Context, sid string, input AppendInput) (Record, error) { + var rec Record + err := s.db.Update(ctx, func(txn *db.Txn) error { + var err error + rec, err = appendRecord(txn, s.clock, sid, input) + return err + }) + return rec, err +} + +// List returns events for sid subject to opts. +func (s *Store) List(ctx context.Context, sid string, opts ListOptions) ([]Record, error) { + var records []Record + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + records, err = listRecords(txn, sid, opts) + return err + }) + return records, err +} + +// LatestSequence returns the latest event sequence for sid. +func (s *Store) LatestSequence(ctx context.Context, sid string) (uint64, error) { + var seq uint64 + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + seq, err = latestSequence(txn, sid) + return err + }) + return seq, err +} + +// TxnStore wraps a db transaction for event operations. +type TxnStore struct { + txn *db.Txn + clock timeutil.Clock +} + +// Append records an event using the wrapped transaction. +func (s TxnStore) Append(sid string, input AppendInput) (Record, error) { + return appendRecord(s.txn, s.clock, sid, input) +} + +// List returns events for sid using opts. +func (s TxnStore) List(sid string, opts ListOptions) ([]Record, error) { + return listRecords(s.txn, sid, opts) +} + +// LatestSequence returns the most recent event sequence. +func (s TxnStore) LatestSequence(sid string) (uint64, error) { + return latestSequence(s.txn, sid) +} + +// Prefix returns the subscription prefix for events in sid. +func Prefix(sid string) []byte { + return db.PrefixSessionEvents(sid) +} + +// Key returns the event key for seq within sid. +func Key(sid string, seq uint64) []byte { + return db.KeySessionEvent(sid, seq) +} + +// SequenceCounterKey returns the key that stores the latest sequence for sid. +func SequenceCounterKey(sid string) []byte { + return db.KeySessionEventSeq(sid) +} + +var nullPayload = json.RawMessage("null") + +func appendRecord(txn *db.Txn, clock timeutil.Clock, sid string, input AppendInput) (Record, error) { + if txn == nil { + return Record{}, errors.New("event: transaction is nil") + } + if !input.Type.Valid() { + return Record{}, ErrInvalidType + } + + cmd := strings.TrimSpace(input.Command) + if cmd == "" { + return Record{}, ErrEmptyCommand + } + + at := input.At + if clock == nil { + clock = timeutil.UTCClock{} + } + if at.IsZero() { + at = clock.Now() + } + at = timeutil.EnsureUTC(at) + + payload, err := marshalPayload(input.Payload) + if err != nil { + return Record{}, err + } + + seq, err := txn.IncrementUint64(db.KeySessionEventSeq(sid), 1) + if err != nil { + return Record{}, err + } + + var reasonPtr *string + if trimmed := strings.TrimSpace(input.Reason); trimmed != "" { + reasonPtr = &trimmed + } + + record := Record{ + Seq: seq, + At: at, + Type: input.Type, + Reason: reasonPtr, + Command: cmd, + Payload: payload, + } + + if err := txn.SetJSON(db.KeySessionEvent(sid, seq), record); err != nil { + return Record{}, err + } + + return record, nil +} + +func listRecords(txn *db.Txn, sid string, opts ListOptions) ([]Record, error) { + if txn == nil { + return nil, errors.New("event: transaction is nil") + } + + var records []Record + err := txn.Iterate(db.IterateOptions{ + Prefix: db.PrefixSessionEvents(sid), + PrefetchValues: true, + }, func(item db.Item) error { + var rec Record + if err := item.ValueJSON(&rec); err != nil { + return err + } + + if rec.Seq <= opts.After { + return nil + } + + records = append(records, rec) + if opts.Limit > 0 && len(records) >= opts.Limit { + return db.ErrTxnAborted + } + + return nil + }) + if err != nil && !errors.Is(err, db.ErrTxnAborted) { + return nil, err + } + + return records, nil +} + +func latestSequence(txn *db.Txn, sid string) (uint64, error) { + if txn == nil { + return 0, errors.New("event: transaction is nil") + } + + key := db.KeySessionEventSeq(sid) + exists, err := txn.Exists(key) + if err != nil { + return 0, err + } + if !exists { + return 0, nil + } + + value, err := txn.Get(key) + if err != nil { + return 0, err + } + + if len(value) != 8 { + return 0, fmt.Errorf("event: corrupt sequence counter for session %q", sid) + } + + return binary.BigEndian.Uint64(value), nil +} + +func marshalPayload(payload any) (json.RawMessage, error) { + if payload == nil { + return nullPayload, nil + } + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + if len(data) == 0 { + return nullPayload, nil + } + return json.RawMessage(data), nil +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bc822c7ee0dc86a015c861500362c5175d0f5dbe --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package event_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.secluded.site/np/internal/event" + "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/task" + "git.secluded.site/np/internal/testutil" +) + +func TestStoreAppendAndList(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + + clock := &testutil.SequenceClock{ + Times: []time.Time{ + time.Date(2025, time.March, 3, 10, 15, 0, 0, time.FixedZone("A", 3600)), + time.Date(2025, time.March, 3, 10, 20, 0, 0, time.FixedZone("B", -3600)), + }, + } + + store := event.NewStore(db, clock) + sid := "sid-events" + + goalDoc := goal.Document{ + Title: "Plan", + Description: "do something", + UpdatedAt: time.Date(2025, time.March, 3, 9, 0, 0, 0, time.FixedZone("C", 7200)), + } + + first, err := store.Append(ctx, sid, event.BuildGoalSet("np g s", " reason ", goalDoc)) + if err != nil { + t.Fatalf("Append goal_set: %v", err) + } + if first.Seq != 1 { + t.Fatalf("Expected seq 1, got %d", first.Seq) + } + if !first.At.Equal(clock.Times[0].UTC()) { + t.Fatalf("At mismatch: want %v got %v", clock.Times[0].UTC(), first.At) + } + if !first.HasReason() { + t.Fatalf("Expected reason") + } + if want := "reason"; *first.Reason != want { + t.Fatalf("Reason mismatch: want %q got %q", want, *first.Reason) + } + var goalPayload event.GoalSetPayload + if err := first.UnmarshalPayload(&goalPayload); err != nil { + t.Fatalf("UnmarshalPayload goal_set: %v", err) + } + if want := event.GoalSnapshotFrom(goalDoc); goalPayload.Goal != want { + t.Fatalf("Goal payload mismatch: want %+v got %+v", want, goalPayload.Goal) + } + + taskAdded := task.Task{ + ID: "abc123", + Title: "Task", + Description: "finish work", + Status: task.StatusPending, + CreatedAt: time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC), + CreatedSeq: 1, + } + + second, err := store.Append(ctx, sid, event.BuildTaskAdded("np t a", "", taskAdded)) + if err != nil { + t.Fatalf("Append task_added: %v", err) + } + if second.Seq != 2 { + t.Fatalf("Expected seq 2, got %d", second.Seq) + } + if second.Reason != nil { + t.Fatalf("Expected nil reason") + } + if !second.At.Equal(clock.Times[1].UTC()) { + t.Fatalf("Second At mismatch: want %v got %v", clock.Times[1].UTC(), second.At) + } + + var taskPayload event.TaskAddedPayload + if err := second.UnmarshalPayload(&taskPayload); err != nil { + t.Fatalf("UnmarshalPayload task_added: %v", err) + } + if taskPayload.Task != taskAdded { + t.Fatalf("Task payload mismatch: want %+v got %+v", taskAdded, taskPayload.Task) + } + + all, err := store.List(ctx, sid, event.ListOptions{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(all) != 2 { + t.Fatalf("Expected 2 events, got %d", len(all)) + } + if all[0].Seq != 1 || all[1].Seq != 2 { + t.Fatalf("Events not ordered: %+v", all) + } + + after, err := store.List(ctx, sid, event.ListOptions{After: 1}) + if err != nil { + t.Fatalf("List after=1: %v", err) + } + if len(after) != 1 || after[0].Seq != 2 { + t.Fatalf("After list mismatch: %+v", after) + } + + limited, err := store.List(ctx, sid, event.ListOptions{Limit: 1}) + if err != nil { + t.Fatalf("List limit=1: %v", err) + } + if len(limited) != 1 || limited[0].Seq != 1 { + t.Fatalf("Limited list mismatch: %+v", limited) + } + + seq, err := store.LatestSequence(ctx, sid) + if err != nil { + t.Fatalf("LatestSequence: %v", err) + } + if seq != 2 { + t.Fatalf("LatestSequence mismatch: want 2 got %d", seq) + } + + emptySeq, err := store.LatestSequence(ctx, "missing") + if err != nil { + t.Fatalf("LatestSequence missing: %v", err) + } + if emptySeq != 0 { + t.Fatalf("LatestSequence should be 0 for missing, got %d", emptySeq) + } +} + +func TestAppendValidation(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + store := event.NewStore(db, &testutil.SequenceClock{Times: []time.Time{time.Now()}}) + + if _, err := store.Append(ctx, "sid", event.AppendInput{Type: "unknown", Command: "cmd"}); !errors.Is(err, event.ErrInvalidType) { + t.Fatalf("expected ErrInvalidType, got %v", err) + } + + if _, err := store.Append(ctx, "sid", event.AppendInput{Type: event.TypeGoalSet, Command: " "}); !errors.Is(err, event.ErrEmptyCommand) { + t.Fatalf("expected ErrEmptyCommand, got %v", err) + } +} diff --git a/internal/event/payloads.go b/internal/event/payloads.go new file mode 100644 index 0000000000000000000000000000000000000000..9fb0c7e1ebd69dc077d8d277f0838092bc3849e7 --- /dev/null +++ b/internal/event/payloads.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package event + +import ( + "time" + + "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/task" + "git.secluded.site/np/internal/timeutil" +) + +// GoalSnapshot captures relevant goal fields for event payloads. +type GoalSnapshot struct { + Title string `json:"title"` + Description string `json:"description"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GoalSetPayload records a goal being established. +type GoalSetPayload struct { + Goal GoalSnapshot `json:"goal"` +} + +// GoalUpdatedPayload records a goal changing. +type GoalUpdatedPayload struct { + Before GoalSnapshot `json:"goal_before"` + After GoalSnapshot `json:"goal_after"` +} + +// TaskAddedPayload records a newly added task. +type TaskAddedPayload struct { + Task task.Task `json:"task"` +} + +// TaskStateSnapshot captures mutable task fields for updates. +type TaskStateSnapshot struct { + Title string `json:"title"` + Description string `json:"description"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TaskUpdatedPayload captures title/description edits. +type TaskUpdatedPayload struct { + TaskID string `json:"task_id"` + Before TaskStateSnapshot `json:"before"` + After TaskStateSnapshot `json:"after"` +} + +// TaskStatusChangedPayload captures status transitions. +type TaskStatusChangedPayload struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + StatusBefore task.Status `json:"status_before"` + StatusAfter task.Status `json:"status_after"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GoalSnapshotFrom converts a goal document into an event snapshot. +func GoalSnapshotFrom(doc goal.Document) GoalSnapshot { + return GoalSnapshot{ + Title: doc.Title, + Description: doc.Description, + UpdatedAt: timeutil.EnsureUTC(doc.UpdatedAt), + } +} + +// TaskStateSnapshotFrom converts a task into a mutable-field snapshot. +func TaskStateSnapshotFrom(t task.Task) TaskStateSnapshot { + return TaskStateSnapshot{ + Title: t.Title, + Description: t.Description, + UpdatedAt: timeutil.EnsureUTC(t.UpdatedAt), + } +} + +// BuildGoalSet constructs AppendInput for goal_set events. +func BuildGoalSet(command, reason string, doc goal.Document) AppendInput { + return AppendInput{ + Type: TypeGoalSet, + Command: command, + Reason: reason, + Payload: GoalSetPayload{ + Goal: GoalSnapshotFrom(doc), + }, + } +} + +// BuildGoalUpdated constructs AppendInput for goal_updated events. +func BuildGoalUpdated(command, reason string, before, after goal.Document) AppendInput { + return AppendInput{ + Type: TypeGoalUpdated, + Command: command, + Reason: reason, + Payload: GoalUpdatedPayload{ + Before: GoalSnapshotFrom(before), + After: GoalSnapshotFrom(after), + }, + } +} + +// BuildTaskAdded constructs AppendInput for task_added events. +func BuildTaskAdded(command, reason string, t task.Task) AppendInput { + return AppendInput{ + Type: TypeTaskAdded, + Command: command, + Reason: reason, + Payload: TaskAddedPayload{ + Task: t, + }, + } +} + +// BuildTaskUpdated constructs AppendInput for task_updated events. +func BuildTaskUpdated(command, reason, taskID string, before, after task.Task) AppendInput { + return AppendInput{ + Type: TypeTaskUpdated, + Command: command, + Reason: reason, + Payload: TaskUpdatedPayload{ + TaskID: taskID, + Before: TaskStateSnapshotFrom(before), + After: TaskStateSnapshotFrom(after), + }, + } +} + +// BuildTaskStatusChanged constructs AppendInput for task_status_changed events. +func BuildTaskStatusChanged(command, reason string, before task.Task, after task.Task) AppendInput { + return AppendInput{ + Type: TypeTaskStatusChange, + Command: command, + Reason: reason, + Payload: TaskStatusChangedPayload{ + TaskID: after.ID, + Title: after.Title, + StatusBefore: before.Status, + StatusAfter: after.Status, + UpdatedAt: timeutil.EnsureUTC(after.UpdatedAt), + }, + } +} diff --git a/internal/goal/goal.go b/internal/goal/goal.go new file mode 100644 index 0000000000000000000000000000000000000000..d8ee940337b25bf7d92ff182ce11b91955cc9065 --- /dev/null +++ b/internal/goal/goal.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package goal + +import ( + "context" + "errors" + "strings" + "time" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/timeutil" +) + +// ErrNotFound is returned when no goal exists for the requested session. +var ErrNotFound = errors.New("goal: not found") + +// ErrEmptyTitle indicates that a goal title was not provided. +var ErrEmptyTitle = errors.New("goal: title is required") + +// Document captures the session goal persisted in the database. +type Document struct { + Title string `json:"title"` + Description string `json:"description"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Store provides high-level helpers for working with session goals. +type Store struct { + db *db.Database + clock timeutil.Clock +} + +// NewStore constructs a Store. When clock is nil, a UTC system clock is used. +func NewStore(database *db.Database, clock timeutil.Clock) *Store { + if clock == nil { + clock = timeutil.UTCClock{} + } + return &Store{ + db: database, + clock: clock, + } +} + +// WithTxn returns a transactional view of the store for use within db.Update. +func (s *Store) WithTxn(txn *db.Txn) TxnStore { + return TxnStore{ + txn: txn, + clock: s.clock, + } +} + +// Get loads the goal for sid using a read-only transaction. +func (s *Store) Get(ctx context.Context, sid string) (Document, error) { + var doc Document + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + doc, err = load(txn, sid) + return err + }) + return doc, err +} + +// Exists reports whether a goal has been set for sid. +func (s *Store) Exists(ctx context.Context, sid string) (bool, error) { + var exists bool + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + exists, err = txn.Exists(db.KeySessionGoal(sid)) + return err + }) + return exists, err +} + +// Set stores a goal for sid using a write transaction, returning the persisted +// document. +func (s *Store) Set(ctx context.Context, sid, title, description string) (Document, error) { + var doc Document + err := s.db.Update(ctx, func(txn *db.Txn) error { + var err error + doc, err = save(txn, s.clock, sid, title, description) + return err + }) + return doc, err +} + +// TxnStore offers goal helpers scoped to an existing transaction. +type TxnStore struct { + txn *db.Txn + clock timeutil.Clock +} + +// Get loads the goal for sid within the wrapped transaction. +func (s TxnStore) Get(sid string) (Document, error) { + return load(s.txn, sid) +} + +// Exists reports whether a goal has been set for sid within the wrapped txn. +func (s TxnStore) Exists(sid string) (bool, error) { + if s.txn == nil { + return false, errors.New("goal: transaction is nil") + } + return s.txn.Exists(db.KeySessionGoal(sid)) +} + +// Key returns the Badger key storing the goal document. This is useful for +// observers that subscribe to goal updates. +func Key(sid string) []byte { + return db.KeySessionGoal(sid) +} + +// Set writes the goal for sid inside the wrapped transaction. +func (s TxnStore) Set(sid, title, description string) (Document, error) { + return save(s.txn, s.clock, sid, title, description) +} + +func load(txn *db.Txn, sid string) (Document, error) { + if txn == nil { + return Document{}, errors.New("goal: transaction is nil") + } + key := db.KeySessionGoal(sid) + exists, err := txn.Exists(key) + if err != nil { + return Document{}, err + } + if !exists { + return Document{}, ErrNotFound + } + + var doc Document + if err := txn.GetJSON(key, &doc); err != nil { + return Document{}, err + } + return doc, nil +} + +func save(txn *db.Txn, clock timeutil.Clock, sid, title, description string) (Document, error) { + if txn == nil { + return Document{}, errors.New("goal: transaction is nil") + } + if clock == nil { + clock = timeutil.UTCClock{} + } + + cleanTitle := strings.TrimSpace(title) + if cleanTitle == "" { + return Document{}, ErrEmptyTitle + } + + doc := Document{ + Title: cleanTitle, + Description: description, + UpdatedAt: timeutil.EnsureUTC(clock.Now()), + } + + if err := txn.SetJSON(db.KeySessionGoal(sid), doc); err != nil { + return Document{}, err + } + return doc, nil +} diff --git a/internal/goal/goal_test.go b/internal/goal/goal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e76d34adf92371325a9a1910d8bb6e1874ea2c56 --- /dev/null +++ b/internal/goal/goal_test.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package goal_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/testutil" + "git.secluded.site/np/internal/timeutil" +) + +func TestStoreSetAndGet(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + + fixed := time.Date(2025, time.January, 12, 8, 30, 0, 0, time.FixedZone("TEST", 2*3600)) + store := goal.NewStore(db, timeutil.FixedClock{Time: fixed}) + + doc, err := store.Set(ctx, "sid-1", " My Goal ", "describe") + if err != nil { + t.Fatalf("Set: %v", err) + } + + if want, got := "My Goal", doc.Title; want != got { + t.Fatalf("Title mismatch: want %q got %q", want, got) + } + if want, got := "describe", doc.Description; want != got { + t.Fatalf("Description mismatch: want %q got %q", want, got) + } + if want, got := fixed.UTC(), doc.UpdatedAt; !want.Equal(got) { + t.Fatalf("UpdatedAt mismatch: want %v got %v", want, got) + } + + loaded, err := store.Get(ctx, "sid-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if loaded != doc { + t.Fatalf("Loaded doc mismatch: want %+v got %+v", doc, loaded) + } + + exists, err := store.Exists(ctx, "sid-1") + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Fatalf("Exists returned false") + } + + exists, err = store.Exists(ctx, "sid-unknown") + if err != nil { + t.Fatalf("Exists unknown: %v", err) + } + if exists { + t.Fatalf("Exists should be false for unknown session") + } +} + +func TestStoreGetMissing(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + store := goal.NewStore(db, timeutil.FixedClock{Time: time.Now()}) + + _, err := store.Get(ctx, "missing") + if !errors.Is(err, goal.ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestStoreSetRequiresTitle(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + store := goal.NewStore(db, timeutil.FixedClock{Time: time.Now()}) + + if _, err := store.Set(ctx, "sid", " ", "desc"); !errors.Is(err, goal.ErrEmptyTitle) { + t.Fatalf("expected ErrEmptyTitle, got %v", err) + } +} diff --git a/internal/task/store.go b/internal/task/store.go new file mode 100644 index 0000000000000000000000000000000000000000..f415f6bea9d8ccf0c89750b6f265149e4e05c843 --- /dev/null +++ b/internal/task/store.go @@ -0,0 +1,427 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package task + +import ( + "context" + "errors" + "sort" + "strings" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/timeutil" +) + +// CreateParams captures the data required to create a new task. +type CreateParams struct { + ID string + Title string + Description string + Status Status + CreatedSeq uint64 +} + +// Mutator modifies a task prior to persistence. +type Mutator func(*Task) error + +// Store coordinates task persistence and retrieval. +type Store struct { + db *db.Database + clock timeutil.Clock +} + +// NewStore constructs a Store. When clock is nil, a UTC system clock is used. +func NewStore(database *db.Database, clock timeutil.Clock) *Store { + if clock == nil { + clock = timeutil.UTCClock{} + } + return &Store{ + db: database, + clock: clock, + } +} + +// WithTxn exposes transactional helpers for use within db.Update. +func (s *Store) WithTxn(txn *db.Txn) TxnStore { + return TxnStore{ + txn: txn, + clock: s.clock, + } +} + +// Create inserts a task into sid. +func (s *Store) Create(ctx context.Context, sid string, params CreateParams) (Task, error) { + var out Task + err := s.db.Update(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.Create(sid, params) + return err + }) + return out, err +} + +// Get retrieves a task by ID. +func (s *Store) Get(ctx context.Context, sid, id string) (Task, error) { + var out Task + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.Get(sid, id) + return err + }) + return out, err +} + +// Update applies mutate to the stored task and persists the result. +func (s *Store) Update(ctx context.Context, sid, id string, mutate Mutator) (Task, error) { + var out Task + err := s.db.Update(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.Update(sid, id, mutate) + return err + }) + return out, err +} + +// UpdateStatus changes the status of a task. +func (s *Store) UpdateStatus(ctx context.Context, sid, id string, status Status) (Task, error) { + var out Task + err := s.db.Update(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.UpdateStatus(sid, id, status) + return err + }) + return out, err +} + +// Delete removes a task from storage. +func (s *Store) Delete(ctx context.Context, sid, id string) error { + return s.db.Update(ctx, func(txn *db.Txn) error { + return TxnStore{txn: txn, clock: s.clock}.Delete(sid, id) + }) +} + +// List returns all tasks for sid sorted by creation order. +func (s *Store) List(ctx context.Context, sid string) ([]Task, error) { + var out []Task + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.List(sid) + return err + }) + return out, err +} + +// ListByStatus returns tasks matching status for sid sorted by creation order. +func (s *Store) ListByStatus(ctx context.Context, sid string, status Status) ([]Task, error) { + var out []Task + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + out, err = TxnStore{txn: txn, clock: s.clock}.ListByStatus(sid, status) + return err + }) + return out, err +} + +// Exists reports whether a task with id is stored for sid. +func (s *Store) Exists(ctx context.Context, sid, id string) (bool, error) { + var exists bool + err := s.db.View(ctx, func(txn *db.Txn) error { + var err error + exists, err = TxnStore{txn: txn, clock: s.clock}.Exists(sid, id) + return err + }) + return exists, err +} + +// TxnStore wraps a db transaction for task operations. +type TxnStore struct { + txn *db.Txn + clock timeutil.Clock +} + +// Create inserts a task into sid using params. +func (s TxnStore) Create(sid string, params CreateParams) (Task, error) { + if s.txn == nil { + return Task{}, errors.New("task: transaction is nil") + } + + title := strings.TrimSpace(params.Title) + if title == "" { + return Task{}, ErrEmptyTitle + } + + status := params.Status + if status == "" { + status = StatusPending + } + if !status.Valid() { + return Task{}, ErrInvalidStatus + } + + id := strings.TrimSpace(params.ID) + if id == "" { + id = GenerateID(sid, title, params.Description) + } + + key := db.KeySessionTask(sid, id) + exists, err := s.txn.Exists(key) + if err != nil { + return Task{}, err + } + if exists { + return Task{}, ErrExists + } + + now := timeutil.EnsureUTC(s.clock.Now()) + task := Task{ + ID: id, + Title: title, + Description: params.Description, + Status: status, + CreatedAt: now, + UpdatedAt: now, + CreatedSeq: params.CreatedSeq, + } + + if err := s.txn.SetJSON(key, task); err != nil { + return Task{}, err + } + if err := addStatusIndex(s.txn, sid, status, id); err != nil { + return Task{}, err + } + + return task, nil +} + +// Get retrieves a task by ID from sid. +func (s TxnStore) Get(sid, id string) (Task, error) { + if s.txn == nil { + return Task{}, errors.New("task: transaction is nil") + } + return loadTask(s.txn, sid, id) +} + +// Update applies mutate to the task and persists changes. +func (s TxnStore) Update(sid, id string, mutate Mutator) (Task, error) { + if s.txn == nil { + return Task{}, errors.New("task: transaction is nil") + } + + current, err := loadTask(s.txn, sid, id) + if err != nil { + return Task{}, err + } + + next := current + if mutate != nil { + if err := mutate(&next); err != nil { + return Task{}, err + } + } + + next.ID = current.ID + next.CreatedAt = current.CreatedAt + next.CreatedSeq = current.CreatedSeq + + next.Title = strings.TrimSpace(next.Title) + if next.Title == "" { + return Task{}, ErrEmptyTitle + } + + if next.Status == "" { + next.Status = current.Status + } + if !next.Status.Valid() { + return Task{}, ErrInvalidStatus + } + + next.UpdatedAt = timeutil.EnsureUTC(s.clock.Now()) + + key := db.KeySessionTask(sid, id) + if err := s.txn.SetJSON(key, next); err != nil { + return Task{}, err + } + + if next.Status != current.Status { + if err := removeStatusIndex(s.txn, sid, current.Status, id); err != nil { + return Task{}, err + } + } + + if err := addStatusIndex(s.txn, sid, next.Status, id); err != nil { + return Task{}, err + } + + return next, nil +} + +// UpdateStatus changes a task's status. +func (s TxnStore) UpdateStatus(sid, id string, status Status) (Task, error) { + if !status.Valid() { + return Task{}, ErrInvalidStatus + } + return s.Update(sid, id, func(t *Task) error { + t.Status = status + return nil + }) +} + +// Delete removes a task by ID. +func (s TxnStore) Delete(sid, id string) error { + if s.txn == nil { + return errors.New("task: transaction is nil") + } + + task, err := loadTask(s.txn, sid, id) + if err != nil { + return err + } + + if err := s.txn.Delete(db.KeySessionTask(sid, id)); err != nil { + return err + } + + return removeStatusIndex(s.txn, sid, task.Status, id) +} + +// List returns all tasks sorted by creation sequence. +func (s TxnStore) List(sid string) ([]Task, error) { + if s.txn == nil { + return nil, errors.New("task: transaction is nil") + } + + var tasks []Task + err := s.txn.Iterate(db.IterateOptions{ + Prefix: db.PrefixSessionTasks(sid), + PrefetchValues: true, + }, func(item db.Item) error { + var entry Task + if err := item.ValueJSON(&entry); err != nil { + return err + } + tasks = append(tasks, entry) + return nil + }) + if err != nil { + return nil, err + } + + sortTasks(tasks) + return tasks, nil +} + +// Exists reports whether a task with id exists. +func (s TxnStore) Exists(sid, id string) (bool, error) { + if s.txn == nil { + return false, errors.New("task: transaction is nil") + } + return s.txn.Exists(db.KeySessionTask(sid, id)) +} + +// ListByStatus returns tasks filtered by status sorted by creation sequence. +func (s TxnStore) ListByStatus(sid string, status Status) ([]Task, error) { + if s.txn == nil { + return nil, errors.New("task: transaction is nil") + } + if !status.Valid() { + return nil, ErrInvalidStatus + } + + var ids []string + err := s.txn.Iterate(db.IterateOptions{ + Prefix: db.PrefixSessionStatusIndex(sid, status.String()), + }, func(item db.Item) error { + ids = append(ids, lastKeySegment(item.KeyString())) + return nil + }) + if err != nil { + return nil, err + } + + tasks := make([]Task, 0, len(ids)) + for _, id := range ids { + task, err := loadTask(s.txn, sid, id) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + + sortTasks(tasks) + return tasks, nil +} + +func loadTask(txn *db.Txn, sid, id string) (Task, error) { + key := db.KeySessionTask(sid, id) + exists, err := txn.Exists(key) + if err != nil { + return Task{}, err + } + if !exists { + return Task{}, ErrNotFound + } + + var task Task + if err := txn.GetJSON(key, &task); err != nil { + return Task{}, err + } + + if task.ID == "" { + task.ID = id + } + return task, nil +} + +func addStatusIndex(txn *db.Txn, sid string, status Status, id string) error { + key := db.KeySessionTaskStatusIndex(sid, status.String(), id) + return txn.Set(key, []byte{}) +} + +func removeStatusIndex(txn *db.Txn, sid string, status Status, id string) error { + key := db.KeySessionTaskStatusIndex(sid, status.String(), id) + exists, err := txn.Exists(key) + if err != nil { + return err + } + if !exists { + return nil + } + return txn.Delete(key) +} + +func sortTasks(tasks []Task) { + sort.Slice(tasks, func(i, j int) bool { + if tasks[i].CreatedSeq != tasks[j].CreatedSeq { + return tasks[i].CreatedSeq < tasks[j].CreatedSeq + } + if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) + } + return tasks[i].ID < tasks[j].ID + }) +} + +func lastKeySegment(key string) string { + idx := strings.LastIndex(key, "/") + if idx == -1 || idx == len(key)-1 { + return key + } + return key[idx+1:] +} + +// Key returns the storage key for a task ID within sid. +func Key(sid, id string) []byte { + return db.KeySessionTask(sid, id) +} + +// Prefix returns the prefix for all task documents in sid. +func Prefix(sid string) []byte { + return db.PrefixSessionTasks(sid) +} + +// StatusPrefix returns the prefix for tasks matching status within sid. +func StatusPrefix(sid string, status Status) []byte { + return db.PrefixSessionStatusIndex(sid, status.String()) +} diff --git a/internal/task/store_test.go b/internal/task/store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f72ed7745dd116b21bdb5da0ae8e40871e1ea1a4 --- /dev/null +++ b/internal/task/store_test.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package task_test + +import ( + "context" + "errors" + "testing" + "time" + + "git.secluded.site/np/internal/task" + "git.secluded.site/np/internal/testutil" +) + +func TestStoreCreateAndList(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + + clock := &testutil.SequenceClock{ + Times: []time.Time{ + time.Date(2025, time.January, 1, 9, 0, 0, 0, time.FixedZone("A", 3600)), + time.Date(2025, time.January, 1, 9, 5, 0, 0, time.FixedZone("B", -3600)), + }, + } + + store := task.NewStore(db, clock) + sid := "sid-create" + + first, err := store.Create(ctx, sid, task.CreateParams{ + Title: " First Task ", + Description: "first description", + CreatedSeq: 2, + }) + if err != nil { + t.Fatalf("Create first: %v", err) + } + + if first.Title != "First Task" { + t.Fatalf("Title not trimmed: %q", first.Title) + } + if first.Status != task.StatusPending { + t.Fatalf("Expected default status pending, got %s", first.Status) + } + if want := clock.Times[0].UTC(); !first.CreatedAt.Equal(want) || !first.UpdatedAt.Equal(want) { + t.Fatalf("Expected timestamps %v, got created %v updated %v", want, first.CreatedAt, first.UpdatedAt) + } + if want := task.GenerateID(sid, "First Task", "first description"); first.ID != want { + t.Fatalf("GenerateID mismatch: want %q got %q", want, first.ID) + } + + second, err := store.Create(ctx, sid, task.CreateParams{ + Title: "Second Task", + Description: "second description", + CreatedSeq: 1, + Status: task.StatusInProgress, + }) + if err != nil { + t.Fatalf("Create second: %v", err) + } + if second.Status != task.StatusInProgress { + t.Fatalf("Explicit status not applied") + } + if want := clock.Times[1].UTC(); !second.CreatedAt.Equal(want) { + t.Fatalf("Second CreatedAt mismatch: want %v got %v", want, second.CreatedAt) + } + + all, err := store.List(ctx, sid) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(all) != 2 { + t.Fatalf("Expected 2 tasks, got %d", len(all)) + } + if all[0].ID != second.ID || all[1].ID != first.ID { + t.Fatalf("Tasks not sorted by CreatedSeq: %+v", all) + } + + pending, err := store.ListByStatus(ctx, sid, task.StatusPending) + if err != nil { + t.Fatalf("ListByStatus pending: %v", err) + } + if len(pending) != 1 || pending[0].ID != first.ID { + t.Fatalf("Pending list mismatch: %+v", pending) + } + + byProgress, err := store.ListByStatus(ctx, sid, task.StatusInProgress) + if err != nil { + t.Fatalf("ListByStatus in_progress: %v", err) + } + if len(byProgress) != 1 || byProgress[0].ID != second.ID { + t.Fatalf("In-progress list mismatch: %+v", byProgress) + } + + exists, err := store.Exists(ctx, sid, first.ID) + if err != nil { + t.Fatalf("Exists: %v", err) + } + if !exists { + t.Fatalf("Expected first task to exist") + } + exists, err = store.Exists(ctx, sid, "missing") + if err != nil { + t.Fatalf("Exists missing: %v", err) + } + if exists { + t.Fatalf("Expected missing task to be false") + } +} + +func TestStoreUpdateAndStatusIndex(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + + clock := &testutil.SequenceClock{ + Times: []time.Time{ + time.Date(2025, time.February, 2, 11, 0, 0, 0, time.FixedZone("A", 7200)), + time.Date(2025, time.February, 2, 12, 0, 0, 0, time.FixedZone("B", -3600)), + time.Date(2025, time.February, 2, 13, 0, 0, 0, time.FixedZone("C", 5400)), + }, + } + + store := task.NewStore(db, clock) + sid := "sid-update" + + created, err := store.Create(ctx, sid, task.CreateParams{ + Title: "Initial", + Description: "Old", + CreatedSeq: 1, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := store.Update(ctx, sid, created.ID, func(tk *task.Task) error { + tk.Title = " Updated Title " + tk.Description = "New" + return nil + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Title != "Updated Title" { + t.Fatalf("Title not trimmed: %q", updated.Title) + } + if updated.Description != "New" { + t.Fatalf("Description not updated") + } + if !updated.UpdatedAt.Equal(clock.Times[1].UTC()) { + t.Fatalf("UpdatedAt mismatch: want %v got %v", clock.Times[1].UTC(), updated.UpdatedAt) + } + + changed, err := store.UpdateStatus(ctx, sid, created.ID, task.StatusCompleted) + if err != nil { + t.Fatalf("UpdateStatus: %v", err) + } + if changed.Status != task.StatusCompleted { + t.Fatalf("Status not updated") + } + if !changed.UpdatedAt.Equal(clock.Times[2].UTC()) { + t.Fatalf("Status UpdatedAt mismatch: want %v got %v", clock.Times[2].UTC(), changed.UpdatedAt) + } + + pending, err := store.ListByStatus(ctx, sid, task.StatusPending) + if err != nil { + t.Fatalf("ListByStatus pending: %v", err) + } + if len(pending) != 0 { + t.Fatalf("Pending should be empty: %+v", pending) + } + + completed, err := store.ListByStatus(ctx, sid, task.StatusCompleted) + if err != nil { + t.Fatalf("ListByStatus completed: %v", err) + } + if len(completed) != 1 || completed[0].ID != created.ID { + t.Fatalf("Completed list mismatch: %+v", completed) + } +} + +func TestStoreValidation(t *testing.T) { + ctx := context.Background() + db := testutil.OpenDB(t) + store := task.NewStore(db, &testutil.SequenceClock{ + Times: []time.Time{time.Now(), time.Now()}, + }) + + sid := "sid-validate" + + created, err := store.Create(ctx, sid, task.CreateParams{ + Title: "Valid", + Description: "desc", + CreatedSeq: 1, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + _, err = store.Update(ctx, sid, created.ID, func(tk *task.Task) error { + tk.Title = " " + return nil + }) + if !errors.Is(err, task.ErrEmptyTitle) { + t.Fatalf("Expected ErrEmptyTitle, got %v", err) + } + + if _, err = store.UpdateStatus(ctx, sid, created.ID, task.Status("bogus")); !errors.Is(err, task.ErrInvalidStatus) { + t.Fatalf("Expected ErrInvalidStatus, got %v", err) + } +} diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 0000000000000000000000000000000000000000..47420ecad72d69d920221e1ea7b76789eef3f493 --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package task + +import ( + "encoding/hex" + "errors" + "strings" + "time" + + "github.com/zeebo/blake3" +) + +// ErrNotFound indicates that a task with the given ID is absent. +var ErrNotFound = errors.New("task: not found") + +// ErrExists indicates that a task already exists when attempting to create it. +var ErrExists = errors.New("task: already exists") + +// ErrEmptyTitle indicates that a title was missing when required. +var ErrEmptyTitle = errors.New("task: title is required") + +// ErrInvalidStatus is returned when a status string is not recognised. +var ErrInvalidStatus = errors.New("task: invalid status") + +// Status captures the lifecycle of a task. +type Status string + +const ( + StatusPending Status = "pending" + StatusInProgress Status = "in_progress" + StatusCompleted Status = "completed" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +// AllStatuses returns the known task statuses. +func AllStatuses() []Status { + return []Status{ + StatusPending, + StatusInProgress, + StatusCompleted, + StatusFailed, + StatusCancelled, + } +} + +// Valid reports whether s matches a known status. +func (s Status) Valid() bool { + switch s { + case StatusPending, + StatusInProgress, + StatusCompleted, + StatusFailed, + StatusCancelled: + return true + default: + return false + } +} + +func (s Status) String() string { + return string(s) +} + +// ParseStatus converts a string to a Status value. +func ParseStatus(v string) (Status, error) { + status := Status(v) + if !status.Valid() { + return "", ErrInvalidStatus + } + return status, nil +} + +// Task represents an individual unit of work tracked within a session. +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedSeq uint64 `json:"created_seq"` +} + +// GenerateID deterministically produces a task identifier for the given +// session, title, and description. +func GenerateID(sessionID, title, description string) string { + normalized := normalizeForHash(title) + "|" + normalizeForHash(description) + "|" + sessionID + + sum := blake3.Sum256([]byte(normalized)) + hexed := hex.EncodeToString(sum[:]) + return hexed[:6] +} + +func normalizeForHash(s string) string { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return "" + } + return strings.Join(strings.Fields(trimmed), " ") +} diff --git a/internal/testutil/clock.go b/internal/testutil/clock.go new file mode 100644 index 0000000000000000000000000000000000000000..682c1a50444d91929eb45469500ac82d47486c9c --- /dev/null +++ b/internal/testutil/clock.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package testutil + +import ( + "time" +) + +// SequenceClock returns the supplied times in order, repeating the final entry +// when no more values remain. +type SequenceClock struct { + Times []time.Time + index int +} + +// Now implements timeutil.Clock semantics. +func (c *SequenceClock) Now() time.Time { + if len(c.Times) == 0 { + return time.Now().UTC() + } + if c.index >= len(c.Times) { + return c.Times[len(c.Times)-1] + } + t := c.Times[c.index] + if c.index < len(c.Times)-1 { + c.index++ + } + return t +} diff --git a/internal/testutil/db.go b/internal/testutil/db.go new file mode 100644 index 0000000000000000000000000000000000000000..9d4778989ede3d7576cb2dc3ae01ca76da933f58 --- /dev/null +++ b/internal/testutil/db.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package testutil + +import ( + "testing" + + "git.secluded.site/np/internal/db" +) + +// OpenDB opens a database in a temporary directory and registers cleanup with t. +func OpenDB(t *testing.T) *db.Database { + t.Helper() + + database, err := db.Open(db.Options{ + Path: t.TempDir(), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Fatalf("closing db: %v", err) + } + }) + + return database +} diff --git a/internal/timeutil/timeutil.go b/internal/timeutil/timeutil.go new file mode 100644 index 0000000000000000000000000000000000000000..6e55037ef79ffec3bc648cf2ef8c6bf60d97e2fb --- /dev/null +++ b/internal/timeutil/timeutil.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package timeutil + +import "time" + +// Clock abstracts time retrieval so callers can substitute deterministic values +// during testing. +type Clock interface { + Now() time.Time +} + +// UTCClock returns the system time in UTC. +type UTCClock struct{} + +// Now implements Clock. +func (UTCClock) Now() time.Time { + return time.Now().UTC() +} + +// FixedClock always reports the same instant. +type FixedClock struct { + Time time.Time +} + +// Now implements Clock. +func (f FixedClock) Now() time.Time { + if f.Time.IsZero() { + return time.Time{} + } + return f.Time +} + +// EnsureUTC normalises t to UTC when it has a location set. +func EnsureUTC(t time.Time) time.Time { + if t.IsZero() { + return t + } + return t.UTC() +}