diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 2f3dc4b..2c1d533 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -56,6 +56,8 @@ func main() { err = cmdServe(ctx, log) case "invite": err = cmdInvite(ctx, os.Args[2:]) + case "report": + err = runReport(ctx, os.Args[2:]) default: usage() os.Exit(2) @@ -77,6 +79,7 @@ usage: tapir invite mint an invitation link for a new user (host-side) tapir list [-limit N] list stored summaries, recent first tapir show show one summary in full + tapir report Stage-0 usage gate: per-user distinct active weeks configuration is via TAPIR_* environment variables (see .env.example). `) diff --git a/cmd/tapir/report.go b/cmd/tapir/report.go new file mode 100644 index 0000000..0031013 --- /dev/null +++ b/cmd/tapir/report.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "text/tabwriter" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct +// weeks. The gate passes when any user reaches it. +const gateThreshold = 2 + +// runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads +// UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only +// TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself). +func runReport(ctx context.Context, _ []string) error { + dsn := os.Getenv(envDSN) + if dsn == "" { + return fmt.Errorf("%s is required", envDSN) + } + s, err := store.New(ctx, dsn) + if err != nil { + return err + } + defer s.Close() + + rows, err := s.ActiveWeeks(ctx) + if err != nil { + return err + } + return formatReport(os.Stdout, rows) +} + +// formatReport renders the per-user week counts and the gate verdict. Pure: no DB, +// no env — so the layout and verdict logic are unit-testable without Postgres. +func formatReport(w io.Writer, rows []store.UserActiveWeeks) error { + if len(rows) == 0 { + _, err := fmt.Fprintln(w, "no users yet") + return err + } + + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "USER\tNAME\tACTIVE_WEEKS\tGATE") + passed := false + for _, r := range rows { + gate := "-" + if r.ActiveWeeks >= gateThreshold { + gate = "PASS" + passed = true + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", r.UserID, orDash(r.DisplayName), r.ActiveWeeks, gate) + } + if err := tw.Flush(); err != nil { + return err + } + + verdict := fmt.Sprintf("\nGate (usage in >= %d distinct weeks): NOT YET MET\n", gateThreshold) + if passed { + verdict = fmt.Sprintf("\nGate (usage in >= %d distinct weeks): PASSED\n", gateThreshold) + } + _, err := fmt.Fprint(w, verdict) + return err +} diff --git a/cmd/tapir/report_test.go b/cmd/tapir/report_test.go new file mode 100644 index 0000000..04dc5b1 --- /dev/null +++ b/cmd/tapir/report_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +func TestFormatReportColumnsAndGatePass(t *testing.T) { + rows := []store.UserActiveWeeks{ + {UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3}, + {UserID: "user-b", DisplayName: "", ActiveWeeks: 1}, + } + + var b strings.Builder + require.NoError(t, formatReport(&b, rows)) + out := b.String() + + require.Contains(t, out, "USER") + require.Contains(t, out, "ACTIVE_WEEKS") + require.Contains(t, out, "Ada") + require.Contains(t, out, "PASSED", "a user at >= 2 weeks passes the gate") + + // The >=2 user is marked PASS; the 1-week user is not. + require.Contains(t, lineContaining(t, out, "user-a"), "PASS") + require.NotContains(t, lineContaining(t, out, "user-b"), "PASS") + require.Contains(t, lineContaining(t, out, "user-b"), "-", "no display name falls back to dash") +} + +func TestFormatReportGateNotMet(t *testing.T) { + rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}} + + var b strings.Builder + require.NoError(t, formatReport(&b, rows)) + require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate") +} + +func TestFormatReportEmpty(t *testing.T) { + var b strings.Builder + require.NoError(t, formatReport(&b, nil)) + require.Contains(t, b.String(), "no users yet") +} diff --git a/internal/adapters/store/report.go b/internal/adapters/store/report.go new file mode 100644 index 0000000..462e412 --- /dev/null +++ b/internal/adapters/store/report.go @@ -0,0 +1,111 @@ +package store + +import ( + "context" + "fmt" + "sort" + + "github.com/jackc/pgx/v5" +) + +// UserActiveWeeks is one row of the Stage-0 gate report: how many DISTINCT +// calendar weeks a user was active in, counting reads (login_events) AND acts +// (summary_actions) together. The gate (VISION/ADR-016) passes when any user +// reaches ActiveWeeks >= 2. +type UserActiveWeeks struct { + UserID string + DisplayName string + ActiveWeeks int +} + +// ActiveWeeks computes per-user distinct-active-weeks for the gate report, most +// active first. +// +// Why per-user iteration rather than one cross-user GROUP BY: the user-owned +// tables are FORCE RLS (migration 003/010) and the production role is a non- +// superuser owner, so a single un-scoped query sees nothing (deny-all). Instead we +// enumerate users from the deliberately un-RLS'd identity map (user_identities, +// migration 004) and count each user's weeks inside withUser, where the GUC scopes +// login_events + summary_actions to that user. No privilege escalation, no policy +// change — the same isolation seam every other read flows through. +// +// Scope note: the enumeration covers users with a Dex identity (the web users the +// gate is about). A CLI-only user created by the store sink without an identity +// row would not appear — out of scope for this gate. +func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) { + userIDs, err := s.identityUserIDs(ctx) + if err != nil { + return nil, err + } + + out := make([]UserActiveWeeks, 0, len(userIDs)) + for _, uid := range userIDs { + row, err := s.activeWeeksFor(ctx, uid) + if err != nil { + return nil, err + } + out = append(out, row) + } + + // Most active first; user_id as a stable tie-break for deterministic output. + sort.SliceStable(out, func(i, j int) bool { + if out[i].ActiveWeeks != out[j].ActiveWeeks { + return out[i].ActiveWeeks > out[j].ActiveWeeks + } + return out[i].UserID < out[j].UserID + }) + return out, nil +} + +// identityUserIDs lists every tapir user_id from the un-RLS'd identity map. It +// runs directly on the pool (no withUser): user_identities carries no user data +// and is intentionally not RLS-enabled, so it is the one table that can be read +// pre-scope to discover who exists. +func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, `SELECT user_id FROM user_identities ORDER BY user_id`) + if err != nil { + return nil, fmt.Errorf("store: list identity users: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: scan identity user: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate identity users: %w", err) + } + return ids, nil +} + +// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and +// reads their display name, RLS-scoped via withUser. The UNION dedups a week that +// has both a login and an action so it counts once. +func (s *Store) activeWeeksFor(ctx context.Context, userID string) (UserActiveWeeks, error) { + res := UserActiveWeeks{UserID: userID} + if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, + `WITH weeks AS ( + SELECT date_trunc('week', seen_at) AS wk + FROM login_events WHERE user_id = $1 + UNION + SELECT date_trunc('week', acted_at) + FROM summary_actions WHERE user_id = $1 + ) + SELECT count(DISTINCT wk) FROM weeks`, userID).Scan(&res.ActiveWeeks); err != nil { + return fmt.Errorf("store: count active weeks: %w", err) + } + if err := tx.QueryRow(ctx, + `SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&res.DisplayName); err != nil { + return fmt.Errorf("store: read display name: %w", err) + } + return nil + }); err != nil { + return UserActiveWeeks{}, err + } + return res, nil +} diff --git a/internal/adapters/store/report_test.go b/internal/adapters/store/report_test.go new file mode 100644 index 0000000..52d5335 --- /dev/null +++ b/internal/adapters/store/report_test.go @@ -0,0 +1,78 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" +) + +// seedReportUser inserts a user + its identity mapping (the enumeration source +// ActiveWeeks reads). display_name is optional. +func seedReportUser(t *testing.T, p *pgxpool.Pool, userID, subject, name string) { + t.Helper() + ctx := context.Background() + _, err := p.Exec(ctx, + `INSERT INTO users (id, display_name) VALUES ($1, NULLIF($2, ''))`, userID, name) + require.NoError(t, err) + _, err = p.Exec(ctx, + `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, userID) + require.NoError(t, err) +} + +// TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs is the gate-query proof. +// It seeds, with fixed timestamps in known ISO weeks: +// - user A: reads in week of Jan 5 and Jan 12, acts in week of Jan 12 (dup) and +// Jan 19 → the UNION across both tables collapses the shared week → 3 distinct. +// - user B: a single read in the week of Jan 5 → 1 distinct (below the gate). +// +// It verifies the count is correct, dedups the cross-table shared week, and orders +// most-active first. +func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) // TRUNCATE ... users CASCADE also clears user_identities + + seedReportUser(t, p, userA, "subject-a", "Ada") + seedReportUser(t, p, userB, "subject-b", "") + + // Reads (login_events) — fixed dates in distinct ISO weeks. + _, err := p.Exec(ctx, + `INSERT INTO login_events (user_id, seen_at) VALUES + ($1, '2026-01-05T09:00:00Z'), + ($1, '2026-01-12T09:00:00Z'), + ($2, '2026-01-05T09:00:00Z')`, userA, userB) + require.NoError(t, err) + + // Acts (summary_actions) — one in A's week-of-Jan-12 (shared with a read, must + // dedup) and one in a new week (Jan 19). + _, err = p.Exec(ctx, + `INSERT INTO summary_actions (user_id, video_id, action, acted_at) VALUES + ($1, 'vid-1', 'watched', '2026-01-12T18:00:00Z'), + ($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA) + require.NoError(t, err) + + got, err := s.ActiveWeeks(ctx) + require.NoError(t, err) + require.Len(t, got, 2, "both identity users must appear") + + require.Equal(t, userA, got[0].UserID, "most-active user first") + require.Equal(t, "Ada", got[0].DisplayName) + require.Equal(t, 3, got[0].ActiveWeeks, "3 distinct weeks across reads+acts, shared week deduped") + + require.Equal(t, userB, got[1].UserID) + require.Equal(t, 1, got[1].ActiveWeeks, "single read = 1 distinct week (below gate)") +} + +// TestActiveWeeksEmptyWhenNoUsers: no identities → no rows (not an error). +func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + got, err := s.ActiveWeeks(ctx) + require.NoError(t, err) + require.Empty(t, got) +}