feat(vibe): adapt sessions to context and exploration
This commit is contained in:
@@ -19,6 +19,46 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType
|
||||
|
||||
describe('DbService v2 methods', () => {
|
||||
describe('durable Vibe sessions', () => {
|
||||
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
|
||||
type: 'completed', occurred_at: new Date(), client_event_id: null,
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
} as any;
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
|
||||
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
|
||||
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
|
||||
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
|
||||
.mockResolvedValueOnce({ rows: [] }) // no audio targets
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
exploration_coefficient: 0.36, discovery_radius: 0.434,
|
||||
goals: { type: 'familiar', target: 1, progress: 1 },
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // session_state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // marker conflict
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
|
||||
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END');
|
||||
expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
|
||||
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, reads, and ends sessions scoped to their user', async () => {
|
||||
const { service, poolQuery, clientQuery } = makeTransactionalService();
|
||||
const session = {
|
||||
@@ -44,12 +84,59 @@ describe('DbService v2 methods', () => {
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
|
||||
'user-1', null, expect.any(String), 'v2.1',
|
||||
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]));
|
||||
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({
|
||||
activity: 'focus',
|
||||
}));
|
||||
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
|
||||
});
|
||||
|
||||
it('normalizes context at the persistence boundary for direct callers', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z'));
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
try {
|
||||
await service.createVibeSession({
|
||||
userId: 'user-1',
|
||||
policyVersion: 'v2.1',
|
||||
context: {
|
||||
timeZone: 'UTC',
|
||||
activity: 'walking',
|
||||
device: 'phone',
|
||||
exactCoordinates: '53.1959,50.1002',
|
||||
browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' },
|
||||
localHour: 3,
|
||||
weekday: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const insertParameters = clientQuery.mock.calls[3][1];
|
||||
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
|
||||
expect(JSON.parse(insertParameters[2])).toEqual({
|
||||
timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend',
|
||||
activity: 'walking', device: 'phone',
|
||||
});
|
||||
expect(insertParameters.slice(3)).toEqual([
|
||||
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces an owned active session and writes its terminal event before starting another', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
|
||||
@@ -102,6 +189,63 @@ describe('DbService v2 methods', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes and projects an inserted context change atomically with its ledger event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { context: { activity: 'walking', localHour: 12 } },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [event] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }) // session context projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // legacy session-state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', type: 'context_changed',
|
||||
payload: {
|
||||
context: { activity: 'walking', exactCoordinates: '53.2,50.1' },
|
||||
rawBrowserTelemetry: { battery: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
const values = clientQuery.mock.calls[2][1];
|
||||
const storedPayload = JSON.parse(values[8]);
|
||||
expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) });
|
||||
expect(storedPayload.context).not.toHaveProperty('exactCoordinates');
|
||||
expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'");
|
||||
});
|
||||
|
||||
it('does not apply a retry body to an existing context-change event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const canonicalEvent = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { context: { activity: 'focus', localHour: 12 } },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed',
|
||||
payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ event: canonicalEvent, inserted: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('projects material feedback once with the durable event transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
|
||||
Reference in New Issue
Block a user