feat(vibe): persist durable session plans and events
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user