feat(vibe): persist durable session plans and events

This commit is contained in:
kami
2026-08-01 22:32:42 +04:00
parent 3641ec9e8e
commit 515cab2f89
6 changed files with 639 additions and 0 deletions
+23
View File
@@ -20,3 +20,26 @@ describe('track release-date migration', () => {
expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)');
});
});
describe('Vibe durable session migration', () => {
const migration = MIGRATIONS.find(
({ id }) => id === '20260801_vibe_session_persistence',
);
it('creates the event ledger, retry key, and revisioned plan tables', () => {
expect(migration).toBeDefined();
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_sessions');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_events');
expect(migration!.sql).toContain('UNIQUE (session_id, client_event_id)');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_versions');
expect(migration!.sql).toContain('UNIQUE (session_id, version)');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_items');
expect(migration!.sql).toContain('UNIQUE (plan_version_id, track_id)');
});
it('indexes session and event reads along their required time axes', () => {
expect(migration!.sql).toContain('idx_vibe_sessions_user_last_event');
expect(migration!.sql).toContain('idx_vibe_events_session_occurred');
expect(migration!.sql).toContain('idx_vibe_events_user_occurred');
});
});
+64
View File
@@ -644,4 +644,68 @@ export const MIGRATIONS: Migration[] = [
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
`,
},
{
// Vibe v2 needs an immutable event ledger and revisioned plans. The
// legacy session_state table remains in place as a derived-state cache so
// existing v2 endpoints can migrate independently.
id: '20260801_vibe_session_persistence',
sql: `
CREATE TABLE IF NOT EXISTS vibe_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
policy_version TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
ON vibe_sessions (user_id, last_event_at DESC);
CREATE TABLE IF NOT EXISTS vibe_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_event_id UUID,
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
position_ms INTEGER,
duration_ms INTEGER,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (session_id, client_event_id)
);
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
ON vibe_events (session_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
ON vibe_events (user_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
reason TEXT NOT NULL,
state_snapshot JSONB NOT NULL,
objective_snapshot JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (session_id, version)
);
CREATE TABLE IF NOT EXISTS vibe_plan_items (
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
slot_role TEXT,
candidate_source TEXT NOT NULL,
score REAL NOT NULL,
score_breakdown JSONB NOT NULL,
explanation JSONB NOT NULL,
committed BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (plan_version_id, ordinal),
UNIQUE (plan_version_id, track_id)
);
`,
},
];
+62
View File
@@ -525,6 +525,68 @@ CREATE TABLE IF NOT EXISTS session_state (
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for
-- the existing director; these tables are the authoritative record for the
-- next-generation, versioned planner.
CREATE TABLE IF NOT EXISTS vibe_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
policy_version TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
ON vibe_sessions (user_id, last_event_at DESC);
CREATE TABLE IF NOT EXISTS vibe_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_event_id UUID,
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
position_ms INTEGER,
duration_ms INTEGER,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (session_id, client_event_id)
);
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
ON vibe_events (session_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
ON vibe_events (user_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
reason TEXT NOT NULL,
state_snapshot JSONB NOT NULL,
objective_snapshot JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (session_id, version)
);
CREATE TABLE IF NOT EXISTS vibe_plan_items (
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
slot_role TEXT,
candidate_source TEXT NOT NULL,
score REAL NOT NULL,
score_breakdown JSONB NOT NULL,
explanation JSONB NOT NULL,
committed BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (plan_version_id, ordinal),
UNIQUE (plan_version_id, track_id)
);
-- Diversity budgets for the session director's planner.
CREATE TABLE IF NOT EXISTS diversity_budgets (
user_id UUID NOT NULL,
+64
View File
@@ -194,3 +194,67 @@ export interface RepetitionRule {
dimension: string;
min_distance: number;
}
// ---------------------------------------------------------------------------
// Vibe v2 durable session ledger
// ---------------------------------------------------------------------------
export const VIBE_SESSION_STATUSES = ['active', 'paused', 'ended', 'expired', 'replaced'] as const;
export type VibeSessionStatus = (typeof VIBE_SESSION_STATUSES)[number];
export interface VibeSession {
id: string;
user_id: string;
status: VibeSessionStatus;
seed_track_id: string | null;
context: Record<string, unknown>;
policy_version: string;
started_at: Date;
last_event_at: Date;
ended_at: Date | null;
}
export interface VibeEvent {
id: string;
client_event_id: string | null;
session_id: string;
user_id: string;
track_id: string | null;
type: string;
occurred_at: Date;
position_ms: number | null;
duration_ms: number | null;
payload: Record<string, unknown>;
}
export interface RecordedVibeEvent {
event: VibeEvent;
/** False when a retried client_event_id returned the original event. */
inserted: boolean;
}
export interface VibePlanVersion {
id: string;
session_id: string;
version: number;
reason: string;
state_snapshot: Record<string, unknown>;
objective_snapshot: Record<string, unknown>;
created_at: Date;
}
export interface VibePlanItem {
plan_version_id: string;
ordinal: number;
track_id: string;
slot_role: string | null;
candidate_source: string;
score: number;
score_breakdown: Record<string, unknown>;
explanation: unknown;
committed: boolean;
}
export interface VibePlan extends VibePlanVersion {
items: VibePlanItem[];
}
+160
View File
@@ -7,7 +7,167 @@ function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn
return { service, mockQuery };
}
function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
const poolQuery = vi.fn();
const clientQuery = vi.fn();
const service = new DbService({
query: poolQuery,
connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
} as any);
return { service, poolQuery, clientQuery };
}
describe('DbService v2 methods', () => {
describe('durable Vibe sessions', () => {
it('creates, reads, and ends sessions scoped to their user', async () => {
const { service, mockQuery } = makeService();
const session = {
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
context: { activity: 'focus' }, policy_version: 'v2.1',
};
mockQuery
.mockResolvedValueOnce({ rows: [session] })
.mockResolvedValueOnce({ rows: [session] })
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
await expect(service.createVibeSession({
userId: 'user-1', policyVersion: 'v2.1', context: { activity: 'focus' },
})).resolves.toEqual(session);
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO vibe_sessions');
expect(mockQuery.mock.calls[0][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
expect(mockQuery.mock.calls[1][0]).toContain('id = $1 AND user_id = $2');
expect(mockQuery.mock.calls[2][0]).toContain('COALESCE(ended_at, NOW())');
expect(mockQuery.mock.calls[2][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
});
it('records retry-safe events and reports whether the event was inserted', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
.mockResolvedValueOnce({ rows: [event] }) // existing retry
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
payload: { reason: 'next' },
});
expect(result.inserted).toBe(false);
expect(result.event.id).toBe('event-1');
const [sql, values] = clientQuery.mock.calls[1];
expect(sql).toContain('FOR UPDATE');
expect(values).toEqual([
'session-1', 'user-1',
]);
expect(clientQuery.mock.calls).toHaveLength(4);
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('UPDATE vibe_sessions')
);
});
it('rejects an event when no owned session is returned', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // session lookup
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'other-user', type: 'completed',
})).rejects.toThrow('not found or is not owned');
});
it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [event] })
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [] }) // no matching retry
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
})).resolves.toEqual({ event, inserted: false });
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
})).rejects.toThrow('Cannot record a new event for ended Vibe session');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('INSERT INTO vibe_events')
);
});
});
describe('durable Vibe plans', () => {
it('writes a header and all items in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
}] })
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const plan = await service.persistVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
items: [{
ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
}],
});
expect(plan.items[0].plan_version_id).toBe('plan-1');
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
});
it('reads the latest revision and reconstructs ordered plan items', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
explanation: [{ because: 'similar artist' }], committed: true,
}, {
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
explanation: [], committed: false,
}] });
const plan = await service.getVibePlan('session-1', 'user-1');
expect(plan?.version).toBe(2);
expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
});
});
describe('createAlbum', () => {
it('persists the canonical release date instead of discarding it', async () => {
const { service, mockQuery } = makeService();
+266
View File
@@ -10,6 +10,18 @@ import { SearchService } from './search.service.js';
/** Anything with a `.query()` — either the shared Pool or a checked-out client. */
type Queryable = Pool | PoolClient;
type VibePlanVersionRow = Omit<VibePlan, 'items'> & {
item_plan_version_id: string | null;
ordinal: number | null;
track_id: string | null;
slot_role: string | null;
candidate_source: string | null;
score: number | null;
score_breakdown: Record<string, unknown> | null;
explanation: unknown | null;
committed: boolean | null;
};
import { MIGRATIONS } from '../db/migrations.js';
import { allowedFields } from '../db/updatable-columns.js';
@@ -33,6 +45,12 @@ import type {
SessionState,
DiversityBudget,
RepetitionRule,
VibeSession,
VibeSessionStatus,
VibeEvent,
RecordedVibeEvent,
VibePlan,
VibePlanItem,
} from '../db/types.js';
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
export * from '../db/types.js';
@@ -1479,6 +1497,254 @@ export class DbService {
return (res.rows[0] as SessionState) || null;
}
/**
* Create the authoritative Vibe v2 session record. This intentionally does
* not create a legacy session_state row: callers can migrate to the durable
* ledger without changing the existing v2 endpoint contract first.
*/
async createVibeSession(params: {
userId: string;
policyVersion: string;
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;
}
/** Fetch a Vibe session only when it belongs to the requesting user. */
async getVibeSession(sessionId: string, userId: string): Promise<VibeSession | null> {
const res = await this.pgClient.query(
'SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2',
[sessionId, userId]
);
return (res.rows[0] as VibeSession) ?? null;
}
/**
* End (or expire/replace) a session without changing its original end time
* when a client retries the same request.
*/
async endVibeSession(
sessionId: string,
userId: string,
status: Extract<VibeSessionStatus, 'ended' | 'expired' | 'replaced'> = 'ended'
): Promise<VibeSession | null> {
const res = await this.pgClient.query(
`UPDATE vibe_sessions
SET status = CASE WHEN ended_at IS NULL THEN $3 ELSE status END,
ended_at = COALESCE(ended_at, NOW()),
last_event_at = CASE WHEN ended_at IS NULL THEN NOW() ELSE last_event_at END
WHERE id = $1 AND user_id = $2
RETURNING *`,
[sessionId, userId, status]
);
return (res.rows[0] as VibeSession) ?? null;
}
/**
* Append an immutable Vibe event. A supplied clientEventId is idempotent per
* session: a retry returns the original event and does not advance the
* session timestamp a second time. A missing id deliberately means a new,
* server-originated event.
*/
async recordVibeEvent(params: {
sessionId: string;
userId: string;
type: string;
clientEventId?: string | null;
trackId?: string | null;
occurredAt?: Date;
positionMs?: number | null;
durationMs?: number | null;
payload?: Record<string, unknown>;
}): Promise<RecordedVibeEvent> {
return this.withTransaction(async (client) => {
// A session-row lock serializes both event writes and terminal state
// transitions. In particular, it avoids the READ COMMITTED CTE snapshot
// race where ON CONFLICT observes a concurrent event but a later CTE
// cannot yet read it. The duplicate lookup happens after the lock, so an
// idempotent retry remains valid even after the session has ended.
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 (params.clientEventId) {
const existingRes = await client.query(
`SELECT *
FROM vibe_events
WHERE session_id = $1 AND client_event_id = $2::uuid`,
[params.sessionId, params.clientEventId]
);
const existing = existingRes.rows[0] as VibeEvent | undefined;
if (existing) {
return { event: existing, inserted: false };
}
}
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
throw new Error(`Cannot record a new event for ${session.status} Vibe session`);
}
const occurredAt = params.occurredAt?.toISOString() ?? null;
const insertRes = await client.query(
`INSERT INTO vibe_events
(client_event_id, session_id, user_id, track_id, type, occurred_at, position_ms, duration_ms, payload)
VALUES ($1::uuid, $2, $3, $4::uuid, $5, COALESCE($6::timestamptz, NOW()), $7, $8, $9::jsonb)
RETURNING *`,
[
params.clientEventId ?? null,
params.sessionId,
params.userId,
params.trackId ?? null,
params.type,
occurredAt,
params.positionMs ?? null,
params.durationMs ?? null,
JSON.stringify(params.payload ?? {}),
]
);
const event = insertRes.rows[0] as VibeEvent | undefined;
if (!event) {
throw new Error('Vibe event could not be recorded');
}
await client.query(
`UPDATE vibe_sessions
SET last_event_at = GREATEST(last_event_at, $2::timestamptz)
WHERE id = $1`,
[params.sessionId, event.occurred_at]
);
return { event, inserted: true };
});
}
/**
* 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 persistVibePlan(params: {
sessionId: string;
userId: string;
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) => {
const header = await client.query(
`INSERT INTO vibe_plan_versions
(session_id, version, reason, state_snapshot, objective_snapshot)
SELECT s.id, $3, $4, $5::jsonb, $6::jsonb
FROM vibe_sessions s
WHERE s.id = $1 AND s.user_id = $2
RETURNING *`,
[
params.sessionId,
params.userId,
params.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 session was not found or is not owned by this user');
}
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 })) };
});
}
/** 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(
`SELECT p.*, i.plan_version_id AS item_plan_version_id, i.ordinal, i.track_id,
i.slot_role, i.candidate_source, i.score, i.score_breakdown,
i.explanation, i.committed
FROM vibe_plan_versions p
JOIN vibe_sessions s ON s.id = p.session_id
LEFT JOIN vibe_plan_items i ON i.plan_version_id = p.id
WHERE p.session_id = $1 AND s.user_id = $2
AND (
($3::integer IS NOT NULL AND p.version = $3)
OR ($3::integer IS NULL AND p.version = (
SELECT MAX(version) FROM vibe_plan_versions WHERE session_id = $1
))
)
ORDER BY i.ordinal ASC`,
[sessionId, userId, version ?? null]
);
if (!res.rows[0]) return null;
const first = res.rows[0] as VibePlanVersionRow;
const plan: VibePlan = {
id: first.id,
session_id: first.session_id,
version: first.version,
reason: first.reason,
state_snapshot: first.state_snapshot,
objective_snapshot: first.objective_snapshot,
created_at: first.created_at,
items: [],
};
for (const row of res.rows as VibePlanVersionRow[]) {
if (!row.item_plan_version_id) continue;
plan.items.push({
plan_version_id: row.item_plan_version_id,
ordinal: row.ordinal!,
track_id: row.track_id!,
slot_role: row.slot_role,
candidate_source: row.candidate_source!,
score: row.score!,
score_breakdown: row.score_breakdown!,
explanation: row.explanation,
committed: row.committed!,
});
}
return plan;
}
/**
* Upsert a diversity budget for a user.
*/