feat(db): add M2 schema — play_sessions, play_events, skip_events
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.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS skip_events;
|
||||||
|
DROP TABLE IF EXISTS play_events;
|
||||||
|
DROP TABLE IF EXISTS play_sessions;
|
||||||
@@ -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);
|
||||||
@@ -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 *;
|
||||||
Reference in New Issue
Block a user