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
+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();