feat(vibe): add durable versioned session API
This commit is contained in:
@@ -1478,10 +1478,17 @@ export class DbService {
|
||||
/**
|
||||
* Create a new session state row.
|
||||
*/
|
||||
async createSessionState(userId: string, context?: string, stateVector?: Record<string, unknown>): Promise<string> {
|
||||
async createSessionState(
|
||||
userId: string,
|
||||
context?: string,
|
||||
stateVector?: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<string> {
|
||||
const res = await this.pgClient.query(
|
||||
`INSERT INTO session_state (user_id, context, state_vector) VALUES ($1, $2, $3) RETURNING session_id`,
|
||||
[userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}']
|
||||
`INSERT INTO session_state (session_id, user_id, context, state_vector)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4::jsonb)
|
||||
RETURNING session_id`,
|
||||
[sessionId ?? null, userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}']
|
||||
);
|
||||
return res.rows[0].session_id as string;
|
||||
}
|
||||
@@ -1508,18 +1515,45 @@ export class DbService {
|
||||
seedTrackId?: string | null;
|
||||
context?: Record<string, unknown>;
|
||||
}): Promise<VibeSession> {
|
||||
const res = await this.pgClient.query(
|
||||
`INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
|
||||
VALUES ($1, 'active', $2, $3::jsonb, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.userId,
|
||||
params.seedTrackId ?? null,
|
||||
JSON.stringify(params.context ?? {}),
|
||||
params.policyVersion,
|
||||
]
|
||||
);
|
||||
return res.rows[0] as VibeSession;
|
||||
return this.withTransaction(async (client) => {
|
||||
// Serialize starts for one listener even when there is no active row to
|
||||
// lock yet. The row lock below then safely replaces any prior session.
|
||||
await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [params.userId]);
|
||||
// Lock every active session first. This makes concurrent starts converge
|
||||
// on one active durable session instead of creating overlapping streams.
|
||||
const active = await client.query(
|
||||
`SELECT id FROM vibe_sessions WHERE user_id = $1 AND status = 'active' FOR UPDATE`,
|
||||
[params.userId],
|
||||
);
|
||||
if (active.rows.length > 0) {
|
||||
const replaced = await client.query(
|
||||
`UPDATE vibe_sessions
|
||||
SET status = 'replaced', ended_at = NOW(), last_event_at = NOW()
|
||||
WHERE user_id = $1 AND status = 'active'
|
||||
RETURNING id`,
|
||||
[params.userId],
|
||||
);
|
||||
for (const session of replaced.rows as Array<{ id: string }>) {
|
||||
await client.query(
|
||||
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
||||
VALUES ($1, $2, 'session_ended', NOW(), '{"reason":"replaced"}'::jsonb)`,
|
||||
[session.id, params.userId],
|
||||
);
|
||||
}
|
||||
}
|
||||
const res = await client.query(
|
||||
`INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
|
||||
VALUES ($1, 'active', $2, $3::jsonb, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.userId,
|
||||
params.seedTrackId ?? null,
|
||||
JSON.stringify(params.context ?? {}),
|
||||
params.policyVersion,
|
||||
],
|
||||
);
|
||||
return res.rows[0] as VibeSession;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a Vibe session only when it belongs to the requesting user. */
|
||||
@@ -1552,6 +1586,70 @@ export class DbService {
|
||||
return (res.rows[0] as VibeSession) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an owned paused/active session exactly once. The row lock makes
|
||||
* the transition and its ledger entry inseparable and prevents retries from
|
||||
* manufacturing a stream of session_resumed events.
|
||||
*/
|
||||
async resumeVibeSession(sessionId: string, userId: string): Promise<{ session: VibeSession; resumed: boolean }> {
|
||||
return this.withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
const session = result.rows[0] as VibeSession | undefined;
|
||||
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
|
||||
throw new Error(`Cannot resume ${session.status} Vibe session`);
|
||||
}
|
||||
|
||||
const prior = await client.query(
|
||||
`SELECT 1 FROM vibe_events WHERE session_id = $1 AND type = 'session_resumed' LIMIT 1`,
|
||||
[sessionId]
|
||||
);
|
||||
if (prior.rowCount) return { session, resumed: false };
|
||||
|
||||
const updated = await client.query(
|
||||
`UPDATE vibe_sessions SET status = 'active', ended_at = NULL, last_event_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[sessionId]
|
||||
);
|
||||
const resumed = updated.rows[0] as VibeSession;
|
||||
await client.query(
|
||||
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
||||
VALUES ($1, $2, 'session_resumed', NOW(), '{}'::jsonb)`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
return { session: resumed, resumed: true };
|
||||
});
|
||||
}
|
||||
|
||||
/** End a session and append its terminal event under one session-row lock. */
|
||||
async endVibeSessionWithEvent(sessionId: string, userId: string): Promise<{ session: VibeSession; ended: boolean }> {
|
||||
return this.withTransaction(async (client) => {
|
||||
const result = await client.query(
|
||||
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
const session = result.rows[0] as VibeSession | undefined;
|
||||
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
|
||||
return { session, ended: false };
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
||||
VALUES ($1, $2, 'session_ended', NOW(), '{}'::jsonb)`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
const updated = await client.query(
|
||||
`UPDATE vibe_sessions SET status = 'ended', ended_at = NOW(), last_event_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[sessionId]
|
||||
);
|
||||
return { session: updated.rows[0] as VibeSession, ended: true };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an immutable Vibe event. A supplied clientEventId is idempotent per
|
||||
* session: a retry returns the original event and does not advance the
|
||||
@@ -1596,6 +1694,7 @@ export class DbService {
|
||||
);
|
||||
const existing = existingRes.rows[0] as VibeEvent | undefined;
|
||||
if (existing) {
|
||||
await this.projectVibeFeedback(existing, client);
|
||||
return { event: existing, inserted: false };
|
||||
}
|
||||
}
|
||||
@@ -1627,6 +1726,8 @@ export class DbService {
|
||||
throw new Error('Vibe event could not be recorded');
|
||||
}
|
||||
|
||||
await this.projectVibeFeedback(event, client);
|
||||
|
||||
await client.query(
|
||||
`UPDATE vibe_sessions
|
||||
SET last_event_at = GREATEST(last_event_at, $2::timestamptz)
|
||||
@@ -1637,11 +1738,178 @@ export class DbService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize Vibe feedback into the listener inputs used by the incumbent
|
||||
* director. The projection marker and every write share the event's
|
||||
* transaction, so a retry either sees the completed projection or performs
|
||||
* it once; it can never double-count a completed/skip/dislike/kept signal.
|
||||
*/
|
||||
private async projectVibeFeedback(event: VibeEvent, client: PoolClient): Promise<void> {
|
||||
if (!event.track_id || !['completed', 'skipped', 'disliked', 'kept'].includes(event.type)) return;
|
||||
const projection = await client.query(
|
||||
`INSERT INTO vibe_event_projections (event_id)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
RETURNING event_id`,
|
||||
[event.id],
|
||||
);
|
||||
if (!projection.rows[0]) return;
|
||||
|
||||
const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString();
|
||||
switch (event.type) {
|
||||
case 'completed':
|
||||
await client.query(
|
||||
`INSERT INTO play_history (user_id, track_id, completed, played_at)
|
||||
VALUES ($1, $2, true, $3::timestamptz)`,
|
||||
[event.user_id, event.track_id, occurredAt],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE tracks
|
||||
SET play_count = play_count + 1, last_played_at = $2::timestamptz
|
||||
WHERE id = $1`,
|
||||
[event.track_id, occurredAt],
|
||||
);
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: event.track_id,
|
||||
signal: 'playback_completed',
|
||||
profile: 'longterm',
|
||||
weight: 0.10,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id },
|
||||
}, client);
|
||||
break;
|
||||
case 'skipped':
|
||||
await client.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [event.track_id]);
|
||||
await client.query(
|
||||
`INSERT INTO feedback (user_id, track_id, action, created_at)
|
||||
VALUES ($1, $2, 'skipped', $3::timestamptz)`,
|
||||
[event.user_id, event.track_id, occurredAt],
|
||||
);
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: event.track_id,
|
||||
signal: 'skip_quick',
|
||||
profile: 'negative',
|
||||
weight: -0.20,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id },
|
||||
}, client);
|
||||
break;
|
||||
case 'disliked':
|
||||
await client.query('UPDATE tracks SET dislike_count = dislike_count + 1 WHERE id = $1', [event.track_id]);
|
||||
await client.query(
|
||||
`INSERT INTO feedback (user_id, track_id, action, created_at)
|
||||
VALUES ($1, $2, 'disliked', $3::timestamptz)`,
|
||||
[event.user_id, event.track_id, occurredAt],
|
||||
);
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: event.track_id,
|
||||
signal: 'hidden',
|
||||
profile: 'negative',
|
||||
weight: -0.60,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id },
|
||||
}, client);
|
||||
break;
|
||||
case 'kept':
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: event.track_id,
|
||||
signal: 'kept',
|
||||
profile: 'longterm',
|
||||
weight: 0.05,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id },
|
||||
}, client);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one complete revision of a session plan atomically. The caller
|
||||
* supplies the monotonically increasing version; session-level scheduling
|
||||
* will own version allocation when the director is migrated to this ledger.
|
||||
*/
|
||||
async publishVibePlan(params: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
/** Supplying one is for the first revision; otherwise allocate the next. */
|
||||
version?: number;
|
||||
reason: string;
|
||||
stateSnapshot: Record<string, unknown>;
|
||||
objectiveSnapshot: Record<string, unknown>;
|
||||
items: Array<Omit<VibePlanItem, 'plan_version_id'>>;
|
||||
}): Promise<VibePlan> {
|
||||
return this.withTransaction(async (client) => {
|
||||
// The session lock is also the concurrency boundary for starts/ends and
|
||||
// plan revisions. In particular, a slow initial planner cannot publish
|
||||
// into a session a newer start has already replaced.
|
||||
const sessionRes = await client.query(
|
||||
`SELECT id, status FROM vibe_sessions
|
||||
WHERE id = $1 AND user_id = $2
|
||||
FOR UPDATE`,
|
||||
[params.sessionId, params.userId],
|
||||
);
|
||||
const session = sessionRes.rows[0] as Pick<VibeSession, 'id' | 'status'> | undefined;
|
||||
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
if (session.status !== 'active') {
|
||||
throw new Error(`Cannot publish a plan for ${session.status} Vibe session`);
|
||||
}
|
||||
const version = params.version ?? Number((await client.query(
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 AS version
|
||||
FROM vibe_plan_versions WHERE session_id = $1`,
|
||||
[params.sessionId],
|
||||
)).rows[0].version);
|
||||
const header = await client.query(
|
||||
`INSERT INTO vibe_plan_versions
|
||||
(session_id, version, reason, state_snapshot, objective_snapshot)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.sessionId,
|
||||
version,
|
||||
params.reason,
|
||||
JSON.stringify(params.stateSnapshot),
|
||||
JSON.stringify(params.objectiveSnapshot),
|
||||
],
|
||||
);
|
||||
const planVersion = header.rows[0] as VibePlan | undefined;
|
||||
if (!planVersion) throw new Error('Vibe plan could not be published');
|
||||
for (const item of params.items) {
|
||||
await client.query(
|
||||
`INSERT INTO vibe_plan_items
|
||||
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
||||
[
|
||||
planVersion.id, item.ordinal, item.track_id, item.slot_role,
|
||||
item.candidate_source, item.score, JSON.stringify(item.score_breakdown),
|
||||
JSON.stringify(item.explanation), item.committed,
|
||||
],
|
||||
);
|
||||
}
|
||||
// Header/items and this ledger event deliberately commit together. A
|
||||
// client retry can therefore find either neither or the same canonical
|
||||
// revision; it can never observe a published event without its plan.
|
||||
await client.query(
|
||||
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
||||
VALUES ($1, $2, 'plan_published', NOW(), $3::jsonb)`,
|
||||
[params.sessionId, params.userId, JSON.stringify({
|
||||
planVersion: planVersion.version,
|
||||
planVersionId: planVersion.id,
|
||||
reason: params.reason,
|
||||
itemCount: params.items.length,
|
||||
feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null,
|
||||
})],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`,
|
||||
[params.sessionId],
|
||||
);
|
||||
return {
|
||||
...planVersion,
|
||||
items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async persistVibePlan(params: {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
@@ -1696,6 +1964,115 @@ export class DbService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Allocate and persist the next immutable revision while holding the session lock. */
|
||||
async persistNextVibePlan(params: Omit<Parameters<DbService['persistVibePlan']>[0], 'version'>): Promise<VibePlan> {
|
||||
return this.withTransaction(async (client) => {
|
||||
const session = await client.query(
|
||||
`SELECT id, status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
||||
[params.sessionId, params.userId]
|
||||
);
|
||||
if (!session.rows[0]) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
if ((session.rows[0] as Pick<VibeSession, 'status'>).status !== 'active') {
|
||||
throw new Error(`Cannot record a new event for ${(session.rows[0] as Pick<VibeSession, 'status'>).status} Vibe session`);
|
||||
}
|
||||
const versionResult = await client.query(
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 AS version FROM vibe_plan_versions WHERE session_id = $1`,
|
||||
[params.sessionId]
|
||||
);
|
||||
const version = Number(versionResult.rows[0].version);
|
||||
const header = await client.query(
|
||||
`INSERT INTO vibe_plan_versions
|
||||
(session_id, version, reason, state_snapshot, objective_snapshot)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) RETURNING *`,
|
||||
[params.sessionId, version, params.reason, JSON.stringify(params.stateSnapshot), JSON.stringify(params.objectiveSnapshot)]
|
||||
);
|
||||
const planVersion = header.rows[0] as VibePlan;
|
||||
for (const item of params.items) {
|
||||
await client.query(
|
||||
`INSERT INTO vibe_plan_items
|
||||
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
||||
[planVersion.id, item.ordinal, item.track_id, item.slot_role, item.candidate_source,
|
||||
item.score, JSON.stringify(item.score_breakdown), JSON.stringify(item.explanation), item.committed]
|
||||
);
|
||||
}
|
||||
return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically commit one item from the latest plan. A version-aware request
|
||||
* acts as an idempotency key: retrying the same expected version receives
|
||||
* the original item, while a replaced plan is returned as stale without
|
||||
* committing any old item. Calls without an expected version preserve the
|
||||
* original legacy cursor behaviour.
|
||||
*/
|
||||
async serveNextVibePlanItem(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
expectedPlanVersion?: number,
|
||||
): Promise<{ item: VibePlanItem | null; stale: boolean }> {
|
||||
return this.withTransaction(async (client) => {
|
||||
const session = await client.query(
|
||||
`SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
||||
[sessionId, userId]
|
||||
);
|
||||
const row = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
|
||||
if (!row) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
if (row.status !== 'active') throw new Error(`Cannot record a new event for ${row.status} Vibe session`);
|
||||
const latest = await client.query(
|
||||
`SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`,
|
||||
[sessionId],
|
||||
);
|
||||
const plan = latest.rows[0] as Pick<VibePlan, 'id' | 'version'> | undefined;
|
||||
if (!plan) return { item: null, stale: false };
|
||||
if (expectedPlanVersion !== undefined && expectedPlanVersion !== plan.version) {
|
||||
return { item: null, stale: true };
|
||||
}
|
||||
|
||||
if (expectedPlanVersion !== undefined) {
|
||||
const prior = await client.query(
|
||||
`SELECT i.*
|
||||
FROM vibe_events e
|
||||
JOIN vibe_plan_items i
|
||||
ON i.plan_version_id = (e.payload->>'planVersionId')::uuid
|
||||
AND i.ordinal = (e.payload->>'ordinal')::integer
|
||||
WHERE e.session_id = $1
|
||||
AND e.type = 'track_served'
|
||||
AND e.payload->>'planVersion' = $2::text
|
||||
ORDER BY e.occurred_at ASC
|
||||
LIMIT 1`,
|
||||
[sessionId, expectedPlanVersion],
|
||||
);
|
||||
const servedPreviously = prior.rows[0] as VibePlanItem | undefined;
|
||||
if (servedPreviously) return { item: servedPreviously, stale: false };
|
||||
}
|
||||
const item = await client.query(
|
||||
`WITH next_item AS (
|
||||
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
|
||||
WHERE i.plan_version_id = $2 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
|
||||
)
|
||||
UPDATE vibe_plan_items i SET committed = true
|
||||
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
|
||||
RETURNING i.*`,
|
||||
[sessionId, plan.id]
|
||||
);
|
||||
const served = item.rows[0] as VibePlanItem | undefined;
|
||||
if (!served) return { item: null, stale: false };
|
||||
await client.query(
|
||||
`INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload)
|
||||
VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`,
|
||||
[sessionId, userId, served.track_id, JSON.stringify({
|
||||
planVersion: plan.version,
|
||||
planVersionId: served.plan_version_id,
|
||||
ordinal: served.ordinal,
|
||||
})]
|
||||
);
|
||||
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
|
||||
return { item: served, stale: false };
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a specific plan revision, or the latest revision for a session. */
|
||||
async getVibePlan(sessionId: string, userId: string, version?: number): Promise<VibePlan | null> {
|
||||
const res = await this.pgClient.query(
|
||||
@@ -1745,6 +2122,41 @@ export class DbService {
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Return the replacement revision caused by a material feedback event. */
|
||||
async getVibePlanForFeedbackEvent(sessionId: string, userId: string, eventId: string): Promise<VibePlan | null> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT version
|
||||
FROM vibe_plan_versions p
|
||||
JOIN vibe_sessions s ON s.id = p.session_id
|
||||
WHERE p.session_id = $1
|
||||
AND s.user_id = $2
|
||||
AND p.objective_snapshot->>'feedbackEventId' = $3
|
||||
ORDER BY p.version DESC
|
||||
LIMIT 1`,
|
||||
[sessionId, userId, eventId],
|
||||
);
|
||||
const version = res.rows[0]?.version as number | undefined;
|
||||
return version === undefined ? null : this.getVibePlan(sessionId, userId, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks exposed by a durable session are never eligible for another
|
||||
* revision of that same session. This includes served items and every
|
||||
* explicit feedback target, not just completed play history.
|
||||
*/
|
||||
async getVibeSessionTrackIds(sessionId: string, userId: string): Promise<string[]> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT DISTINCT e.track_id
|
||||
FROM vibe_events e
|
||||
JOIN vibe_sessions s ON s.id = e.session_id
|
||||
WHERE e.session_id = $1
|
||||
AND s.user_id = $2
|
||||
AND e.track_id IS NOT NULL`,
|
||||
[sessionId, userId],
|
||||
);
|
||||
return res.rows.map((row: { track_id: string }) => row.track_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a diversity budget for a user.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user