From 4fcf2c9616c6a2e07345e9b30fd70274899d3cbd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 20:45:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(db):=20add=20M2=20schema=20=E2=80=94=20pla?= =?UTF-8?q?y=5Fsessions,=20play=5Fevents,=20skip=5Fevents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tables and indexes per spec §5. session_vector_at_play ships nullable so M3 doesn't need a follow-up migration. Table is named play_sessions to avoid collision with the existing sessions (HTTP auth) table from migration 0004. --- internal/db/dbq/events.sql.go | 253 ++++++++++++++++++++ internal/db/migrations/0005_events.down.sql | 3 + internal/db/migrations/0005_events.up.sql | 46 ++++ internal/db/queries/events.sql | 50 ++++ 4 files changed, 352 insertions(+) create mode 100644 internal/db/dbq/events.sql.go create mode 100644 internal/db/migrations/0005_events.down.sql create mode 100644 internal/db/migrations/0005_events.up.sql create mode 100644 internal/db/queries/events.sql diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go new file mode 100644 index 00000000..15f94543 --- /dev/null +++ b/internal/db/dbq/events.sql.go @@ -0,0 +1,253 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: events.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one +SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions +WHERE user_id = $1 +ORDER BY last_event_at DESC +LIMIT 1 +` + +func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pgtype.UUID) (PlaySession, error) { + row := q.db.QueryRow(ctx, getMostRecentPlaySessionForUser, userID) + var i PlaySession + err := row.Scan( + &i.ID, + &i.UserID, + &i.StartedAt, + &i.EndedAt, + &i.LastEventAt, + &i.TrackCount, + &i.ClientID, + ) + return i, err +} + +const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one +SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events +WHERE user_id = $1 AND ended_at IS NULL +ORDER BY started_at DESC +LIMIT 1 +` + +// Returns the most recent play_event for a user where ended_at IS NULL. +// Used by the auto-close-prior step in playevents.RecordPlayStarted. +func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUID) (PlayEvent, error) { + row := q.db.QueryRow(ctx, getOpenPlayEventForUser, userID) + var i PlayEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.TrackID, + &i.SessionID, + &i.StartedAt, + &i.EndedAt, + &i.DurationPlayedMs, + &i.CompletionRatio, + &i.WasSkipped, + &i.ClientID, + &i.SessionVectorAtPlay, + &i.ScrobbledAt, + ) + return i, err +} + +const getPlayEventByID = `-- name: GetPlayEventByID :one +SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events WHERE id = $1 +` + +func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) { + row := q.db.QueryRow(ctx, getPlayEventByID, id) + var i PlayEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.TrackID, + &i.SessionID, + &i.StartedAt, + &i.EndedAt, + &i.DurationPlayedMs, + &i.CompletionRatio, + &i.WasSkipped, + &i.ClientID, + &i.SessionVectorAtPlay, + &i.ScrobbledAt, + ) + return i, err +} + +const insertPlayEvent = `-- name: InsertPlayEvent :one +INSERT INTO play_events ( + user_id, track_id, session_id, started_at, client_id +) VALUES ($1, $2, $3, $4, $5) +RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at +` + +type InsertPlayEventParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID + SessionID pgtype.UUID + StartedAt pgtype.Timestamptz + ClientID *string +} + +func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) { + row := q.db.QueryRow(ctx, insertPlayEvent, + arg.UserID, + arg.TrackID, + arg.SessionID, + arg.StartedAt, + arg.ClientID, + ) + var i PlayEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.TrackID, + &i.SessionID, + &i.StartedAt, + &i.EndedAt, + &i.DurationPlayedMs, + &i.CompletionRatio, + &i.WasSkipped, + &i.ClientID, + &i.SessionVectorAtPlay, + &i.ScrobbledAt, + ) + return i, err +} + +const insertPlaySession = `-- name: InsertPlaySession :one +INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) +VALUES ($1, $2, $2, $3) +RETURNING id, user_id, started_at, ended_at, last_event_at, track_count, client_id +` + +type InsertPlaySessionParams struct { + UserID pgtype.UUID + StartedAt pgtype.Timestamptz + ClientID *string +} + +func (q *Queries) InsertPlaySession(ctx context.Context, arg InsertPlaySessionParams) (PlaySession, error) { + row := q.db.QueryRow(ctx, insertPlaySession, arg.UserID, arg.StartedAt, arg.ClientID) + var i PlaySession + err := row.Scan( + &i.ID, + &i.UserID, + &i.StartedAt, + &i.EndedAt, + &i.LastEventAt, + &i.TrackCount, + &i.ClientID, + ) + return i, err +} + +const insertSkipEvent = `-- name: InsertSkipEvent :one +INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, user_id, track_id, session_id, skipped_at, position_ms +` + +type InsertSkipEventParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID + SessionID pgtype.UUID + SkippedAt pgtype.Timestamptz + PositionMs int32 +} + +func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams) (SkipEvent, error) { + row := q.db.QueryRow(ctx, insertSkipEvent, + arg.UserID, + arg.TrackID, + arg.SessionID, + arg.SkippedAt, + arg.PositionMs, + ) + var i SkipEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.TrackID, + &i.SessionID, + &i.SkippedAt, + &i.PositionMs, + ) + return i, err +} + +const touchPlaySessionLastEvent = `-- name: TouchPlaySessionLastEvent :exec +UPDATE play_sessions +SET last_event_at = $2, + track_count = track_count + 1 +WHERE id = $1 +` + +type TouchPlaySessionLastEventParams struct { + ID pgtype.UUID + LastEventAt pgtype.Timestamptz +} + +func (q *Queries) TouchPlaySessionLastEvent(ctx context.Context, arg TouchPlaySessionLastEventParams) error { + _, err := q.db.Exec(ctx, touchPlaySessionLastEvent, arg.ID, arg.LastEventAt) + return err +} + +const updatePlayEventEnded = `-- name: UpdatePlayEventEnded :one +UPDATE play_events +SET ended_at = $2, + duration_played_ms = $3, + completion_ratio = $4, + was_skipped = $5 +WHERE id = $1 +RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at +` + +type UpdatePlayEventEndedParams struct { + ID pgtype.UUID + EndedAt pgtype.Timestamptz + DurationPlayedMs *int32 + CompletionRatio *float64 + WasSkipped bool +} + +// Closes a play_event by id with the given ended_at, duration, and skip flag. +// completion_ratio is computed from duration_played_ms and the track duration +// (looked up by the caller — sqlc doesn't do joins on UPDATE). +func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventEndedParams) (PlayEvent, error) { + row := q.db.QueryRow(ctx, updatePlayEventEnded, + arg.ID, + arg.EndedAt, + arg.DurationPlayedMs, + arg.CompletionRatio, + arg.WasSkipped, + ) + var i PlayEvent + err := row.Scan( + &i.ID, + &i.UserID, + &i.TrackID, + &i.SessionID, + &i.StartedAt, + &i.EndedAt, + &i.DurationPlayedMs, + &i.CompletionRatio, + &i.WasSkipped, + &i.ClientID, + &i.SessionVectorAtPlay, + &i.ScrobbledAt, + ) + return i, err +} diff --git a/internal/db/migrations/0005_events.down.sql b/internal/db/migrations/0005_events.down.sql new file mode 100644 index 00000000..96175523 --- /dev/null +++ b/internal/db/migrations/0005_events.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS skip_events; +DROP TABLE IF EXISTS play_events; +DROP TABLE IF EXISTS play_sessions; diff --git a/internal/db/migrations/0005_events.up.sql b/internal/db/migrations/0005_events.up.sql new file mode 100644 index 00000000..8b17dd2f --- /dev/null +++ b/internal/db/migrations/0005_events.up.sql @@ -0,0 +1,46 @@ +-- M2 listening telemetry: play_sessions, play_events, skip_events. +-- Note the table is named play_sessions (not sessions — that table already +-- exists from migration 0004 and holds HTTP auth sessions). Listening +-- sessions are an entirely separate concept driven by spec §6. +-- +-- session_vector_at_play and contextual_likes ship as nullable now even +-- though M2 writes NULL; M3 will populate them without a follow-up +-- migration. + +CREATE TABLE play_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at timestamptz NOT NULL, + ended_at timestamptz, + last_event_at timestamptz NOT NULL, + track_count integer NOT NULL DEFAULT 0, + client_id text +); +CREATE INDEX play_sessions_user_last_event_idx ON play_sessions (user_id, last_event_at DESC); + +CREATE TABLE play_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE, + started_at timestamptz NOT NULL, + ended_at timestamptz, + duration_played_ms integer, + completion_ratio double precision, + was_skipped boolean NOT NULL DEFAULT false, + client_id text, + session_vector_at_play jsonb, + scrobbled_at timestamptz +); +CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC); +CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id); + +CREATE TABLE skip_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE, + skipped_at timestamptz NOT NULL, + position_ms integer NOT NULL +); +CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC); diff --git a/internal/db/queries/events.sql b/internal/db/queries/events.sql new file mode 100644 index 00000000..aa5fe2ed --- /dev/null +++ b/internal/db/queries/events.sql @@ -0,0 +1,50 @@ +-- name: GetMostRecentPlaySessionForUser :one +SELECT * FROM play_sessions +WHERE user_id = $1 +ORDER BY last_event_at DESC +LIMIT 1; + +-- name: InsertPlaySession :one +INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) +VALUES ($1, $2, $2, $3) +RETURNING *; + +-- name: TouchPlaySessionLastEvent :exec +UPDATE play_sessions +SET last_event_at = $2, + track_count = track_count + 1 +WHERE id = $1; + +-- name: GetOpenPlayEventForUser :one +-- Returns the most recent play_event for a user where ended_at IS NULL. +-- Used by the auto-close-prior step in playevents.RecordPlayStarted. +SELECT * FROM play_events +WHERE user_id = $1 AND ended_at IS NULL +ORDER BY started_at DESC +LIMIT 1; + +-- name: InsertPlayEvent :one +INSERT INTO play_events ( + user_id, track_id, session_id, started_at, client_id +) VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: UpdatePlayEventEnded :one +-- Closes a play_event by id with the given ended_at, duration, and skip flag. +-- completion_ratio is computed from duration_played_ms and the track duration +-- (looked up by the caller — sqlc doesn't do joins on UPDATE). +UPDATE play_events +SET ended_at = $2, + duration_played_ms = $3, + completion_ratio = $4, + was_skipped = $5 +WHERE id = $1 +RETURNING *; + +-- name: GetPlayEventByID :one +SELECT * FROM play_events WHERE id = $1; + +-- name: InsertSkipEvent :one +INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms) +VALUES ($1, $2, $3, $4, $5) +RETURNING *;