feat(session): add session lifecycle management

Amolith and Crush created

Implements Store with Start, Get, Archive, and ActiveByPath operations.
Sessions use ULID-based identifiers and track state transitions between
active and archived.

Co-authored-by: Crush <crush@charm.land>

Change summary

internal/session/session.go      | 286 ++++++++++++++++++++++++++++++++++
internal/session/session_test.go | 193 ++++++++++++++++++++++
2 files changed, 479 insertions(+)

Detailed changes

internal/session/session.go 🔗

@@ -0,0 +1,286 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package session
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/base32"
+	"errors"
+	"fmt"
+	"io"
+	"time"
+
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/timeutil"
+)
+
+// State captures the lifecycle of a session.
+type State string
+
+const (
+	// StateActive indicates that the session is currently active for a directory.
+	StateActive State = "active"
+	// StateArchived indicates that the session has been archived.
+	StateArchived State = "archived"
+)
+
+// Document represents the persisted session metadata.
+type Document struct {
+	SID           string     `json:"sid"`
+	DirPath       string     `json:"dir_path"`
+	DirHash       string     `json:"dir_hash"`
+	State         State      `json:"state"`
+	CreatedAt     time.Time  `json:"created_at"`
+	ArchivedAt    *time.Time `json:"archived_at"`
+	LastUpdatedAt time.Time  `json:"last_updated_at"`
+}
+
+// ErrNotFound indicates that a session could not be located.
+var ErrNotFound = errors.New("session: not found")
+
+// AlreadyActiveError is returned when attempting to start a session where one already exists.
+type AlreadyActiveError struct {
+	Session Document
+}
+
+func (e AlreadyActiveError) Error() string {
+	return "session: active session already exists for directory"
+}
+
+// Store provides helpers for session lifecycle management.
+type Store struct {
+	db    *db.Database
+	clock timeutil.Clock
+}
+
+// NewStore constructs a Store. When clock is nil, a UTC clock is used.
+func NewStore(database *db.Database, clock timeutil.Clock) *Store {
+	if clock == nil {
+		clock = timeutil.UTCClock{}
+	}
+	return &Store{
+		db:    database,
+		clock: clock,
+	}
+}
+
+// Start creates a new session bound to path. When a session already exists for
+// the directory, an AlreadyActiveError is returned containing the existing
+// session document.
+func (s *Store) Start(ctx context.Context, path string) (Document, error) {
+	if s.db == nil {
+		return Document{}, errors.New("session: database is nil")
+	}
+
+	canonical, hash, err := db.CanonicalizeAndHash(path)
+	if err != nil {
+		return Document{}, fmt.Errorf("session: canonicalise path: %w", err)
+	}
+
+	var doc Document
+	err = s.db.Update(ctx, func(txn *db.Txn) error {
+		keyActive := db.KeyDirActive(hash)
+		exists, err := txn.Exists(keyActive)
+		if err != nil {
+			return err
+		}
+		if exists {
+			sidBytes, err := txn.Get(keyActive)
+			if err != nil {
+				return err
+			}
+			sid := string(sidBytes)
+			metaKey := db.KeySessionMeta(sid)
+			already, err := txn.Exists(metaKey)
+			if err != nil {
+				return err
+			}
+			if !already {
+				return fmt.Errorf("session: active session %q missing metadata", sid)
+			}
+			var existing Document
+			if err := txn.GetJSON(metaKey, &existing); err != nil {
+				return err
+			}
+			return AlreadyActiveError{Session: existing}
+		}
+
+		now := timeutil.EnsureUTC(s.clock.Now())
+		sid, err := newSessionID(now)
+		if err != nil {
+			return err
+		}
+
+		doc = Document{
+			SID:           sid,
+			DirPath:       canonical,
+			DirHash:       hash,
+			State:         StateActive,
+			CreatedAt:     now,
+			ArchivedAt:    nil,
+			LastUpdatedAt: now,
+		}
+
+		if err := txn.SetJSON(db.KeySessionMeta(sid), doc); err != nil {
+			return err
+		}
+		if err := txn.Set(db.KeyDirActive(hash), []byte(sid)); err != nil {
+			return err
+		}
+		if err := txn.Set(db.KeyIdxActive(sid), []byte(hash)); err != nil {
+			return err
+		}
+		if err := txn.Set(db.KeySessionEventSeq(sid), make([]byte, 8)); err != nil {
+			return err
+		}
+		return nil
+	})
+	if err != nil {
+		var already AlreadyActiveError
+		if errors.As(err, &already) {
+			return already.Session, already
+		}
+		return Document{}, err
+	}
+
+	return doc, nil
+}
+
+// Get retrieves a session by SID.
+func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
+	var doc Document
+	err := s.db.View(ctx, func(txn *db.Txn) error {
+		metaKey := db.KeySessionMeta(sid)
+		exists, err := txn.Exists(metaKey)
+		if err != nil {
+			return err
+		}
+		if !exists {
+			return ErrNotFound
+		}
+		return txn.GetJSON(metaKey, &doc)
+	})
+	return doc, err
+}
+
+var crockfordEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
+
+func newSessionID(now time.Time) (string, error) {
+	ms := uint64(now.UnixMilli())
+
+	var data [16]byte
+	data[0] = byte(ms >> 40)
+	data[1] = byte(ms >> 32)
+	data[2] = byte(ms >> 24)
+	data[3] = byte(ms >> 16)
+	data[4] = byte(ms >> 8)
+	data[5] = byte(ms)
+
+	if _, err := io.ReadFull(rand.Reader, data[6:]); err != nil {
+		return "", fmt.Errorf("session: generate randomness: %w", err)
+	}
+
+	id := crockfordEncoding.EncodeToString(data[:])
+	if len(id) != 26 {
+		return "", fmt.Errorf("session: unexpected ulid length %d", len(id))
+	}
+	return id, nil
+}
+
+// ActiveByPath returns the active session for path or any of its parents. The
+// returned boolean reports whether a session was found.
+func (s *Store) ActiveByPath(ctx context.Context, path string) (Document, bool, error) {
+	canonical, err := db.CanonicalizeDir(path)
+	if err != nil {
+		return Document{}, false, fmt.Errorf("session: canonicalise path: %w", err)
+	}
+
+	var doc Document
+	found := false
+
+	err = s.db.View(ctx, func(txn *db.Txn) error {
+		for _, candidate := range db.ParentWalk(canonical) {
+			hash := db.DirHash(candidate)
+			keyActive := db.KeyDirActive(hash)
+
+			exists, err := txn.Exists(keyActive)
+			if err != nil {
+				return err
+			}
+			if !exists {
+				continue
+			}
+
+			sidBytes, err := txn.Get(keyActive)
+			if err != nil {
+				return err
+			}
+			metaKey := db.KeySessionMeta(string(sidBytes))
+			if err := txn.GetJSON(metaKey, &doc); err != nil {
+				return err
+			}
+			found = true
+			return nil
+		}
+		return nil
+	})
+	if err != nil {
+		return Document{}, false, err
+	}
+	return doc, found, nil
+}
+
+// Archive transitions sid to the archived state. When the session is already
+// archived, the stored document is returned without error.
+func (s *Store) Archive(ctx context.Context, sid string) (Document, error) {
+	if s.db == nil {
+		return Document{}, errors.New("session: database is nil")
+	}
+
+	var doc Document
+	err := s.db.Update(ctx, func(txn *db.Txn) error {
+		metaKey := db.KeySessionMeta(sid)
+		exists, err := txn.Exists(metaKey)
+		if err != nil {
+			return err
+		}
+		if !exists {
+			return ErrNotFound
+		}
+		if err := txn.GetJSON(metaKey, &doc); err != nil {
+			return err
+		}
+		if doc.State == StateArchived {
+			return nil
+		}
+
+		now := timeutil.EnsureUTC(s.clock.Now())
+		doc.State = StateArchived
+		doc.LastUpdatedAt = now
+		doc.ArchivedAt = &now
+
+		if err := txn.Delete(db.KeyDirActive(doc.DirHash)); err != nil {
+			return err
+		}
+		if err := txn.Delete(db.KeyIdxActive(doc.SID)); err != nil {
+			return err
+		}
+
+		tsHex := db.Uint64Hex(uint64(now.UnixNano()))
+		if err := txn.Set(db.KeyIdxArchived(tsHex, doc.SID), []byte(doc.DirHash)); err != nil {
+			return err
+		}
+		if err := txn.Set(db.KeyDirArchived(doc.DirHash, tsHex, doc.SID), []byte{}); err != nil {
+			return err
+		}
+		if err := txn.SetJSON(metaKey, doc); err != nil {
+			return err
+		}
+		return nil
+	})
+
+	return doc, err
+}

internal/session/session_test.go 🔗

@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package session_test
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"git.secluded.site/np/internal/db"
+	"git.secluded.site/np/internal/session"
+	"git.secluded.site/np/internal/testutil"
+)
+
+func TestStoreStartAndArchive(t *testing.T) {
+	ctx := context.Background()
+	database := testutil.OpenDB(t)
+
+	clock := &testutil.SequenceClock{
+		Times: []time.Time{
+			time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)),
+			time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)),
+			time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC),
+		},
+	}
+
+	store := session.NewStore(database, clock)
+
+	workdir := t.TempDir()
+	canonical, hash, err := db.CanonicalizeAndHash(workdir)
+	if err != nil {
+		t.Fatalf("canonicalise temp dir: %v", err)
+	}
+
+	started, err := store.Start(ctx, workdir)
+	if err != nil {
+		t.Fatalf("Start: %v", err)
+	}
+
+	if started.SID == "" {
+		t.Fatalf("expected SID to be populated")
+	}
+	if len(started.SID) != 26 {
+		t.Fatalf("expected ULID SID length 26, got %d", len(started.SID))
+	}
+	if started.DirPath != canonical {
+		t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath)
+	}
+	if started.DirHash != hash {
+		t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash)
+	}
+	if started.State != session.StateActive {
+		t.Fatalf("expected state active, got %s", started.State)
+	}
+	if started.ArchivedAt != nil {
+		t.Fatalf("expected ArchivedAt to be nil")
+	}
+
+	createdAt := clock.Times[0].UTC()
+	if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) {
+		t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt)
+	}
+
+	err = database.View(ctx, func(txn *db.Txn) error {
+		active, err := txn.Get(db.KeyDirActive(hash))
+		if err != nil {
+			return err
+		}
+		if string(active) != started.SID {
+			t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID)
+		}
+
+		idxActive, err := txn.Get(db.KeyIdxActive(started.SID))
+		if err != nil {
+			return err
+		}
+		if string(idxActive) != hash {
+			t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash)
+		}
+
+		seq, err := txn.Get(db.KeySessionEventSeq(started.SID))
+		if err != nil {
+			return err
+		}
+		if len(seq) != 8 {
+			t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq))
+		}
+		for i, b := range seq {
+			if b != 0 {
+				t.Fatalf("expected zeroed event seq, byte %d was %d", i, b)
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("validate stored keys: %v", err)
+	}
+
+	_, err = store.Start(ctx, workdir)
+	if err == nil {
+		t.Fatalf("expected AlreadyActiveError")
+	}
+	var already session.AlreadyActiveError
+	if !errors.As(err, &already) {
+		t.Fatalf("expected AlreadyActiveError, got %v", err)
+	}
+	if already.Session.SID != started.SID {
+		t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID)
+	}
+
+	childDir := filepath.Join(workdir, "nested")
+	if err := os.MkdirAll(childDir, 0o755); err != nil {
+		t.Fatalf("mkdir nested: %v", err)
+	}
+
+	found, ok, err := store.ActiveByPath(ctx, childDir)
+	if err != nil {
+		t.Fatalf("ActiveByPath child: %v", err)
+	}
+	if !ok {
+		t.Fatalf("expected ActiveByPath to find parent session")
+	}
+	if found.SID != started.SID {
+		t.Fatalf("ActiveByPath returned different SID: %s", found.SID)
+	}
+
+	archived, err := store.Archive(ctx, started.SID)
+	if err != nil {
+		t.Fatalf("Archive: %v", err)
+	}
+	if archived.State != session.StateArchived {
+		t.Fatalf("expected archived state, got %s", archived.State)
+	}
+	wantArchivedAt := clock.Times[1].UTC()
+	if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) {
+		t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt)
+	}
+	if !archived.LastUpdatedAt.Equal(wantArchivedAt) {
+		t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt)
+	}
+
+	err = database.View(ctx, func(txn *db.Txn) error {
+		if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil {
+			return err
+		} else if exists {
+			t.Fatalf("expected dir active key to be removed")
+		}
+		if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil {
+			return err
+		} else if exists {
+			t.Fatalf("expected idx active key to be removed")
+		}
+
+		tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano()))
+		idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID))
+		if err != nil {
+			return err
+		}
+		if string(idxVal) != hash {
+			t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash)
+		}
+
+		dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID)
+		if exists, err := txn.Exists(dirArchivedKey); err != nil {
+			return err
+		} else if !exists {
+			t.Fatalf("expected dir archived key to exist")
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("validate archival keys: %v", err)
+	}
+
+	repeat, err := store.Archive(ctx, started.SID)
+	if err != nil {
+		t.Fatalf("Archive second time: %v", err)
+	}
+	if repeat.State != session.StateArchived {
+		t.Fatalf("expected archived state on second archive")
+	}
+
+	if _, found, err := store.ActiveByPath(ctx, workdir); err != nil {
+		t.Fatalf("post-archive ActiveByPath error: %v", err)
+	} else if found {
+		t.Fatalf("ActiveByPath should not find archived session")
+	}
+}