Detailed changes
@@ -0,0 +1,311 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,151 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+}
@@ -0,0 +1,144 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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),
+ },
+ }
+}
@@ -0,0 +1,162 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+}
@@ -0,0 +1,427 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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())
+}
@@ -0,0 +1,211 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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)
+ }
+}
@@ -0,0 +1,104 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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), " ")
+}
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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()
+}