feat(vibe): add durable versioned session API
This commit is contained in:
@@ -20,13 +20,18 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType
|
||||
describe('DbService v2 methods', () => {
|
||||
describe('durable Vibe sessions', () => {
|
||||
it('creates, reads, and ends sessions scoped to their user', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
const { service, poolQuery, clientQuery } = makeTransactionalService();
|
||||
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] })
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
poolQuery
|
||||
.mockResolvedValueOnce({ rows: [session] })
|
||||
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
|
||||
|
||||
@@ -36,11 +41,32 @@ describe('DbService v2 methods', () => {
|
||||
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');
|
||||
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(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('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' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // new session
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
|
||||
.resolves.toEqual(replacement);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
|
||||
});
|
||||
|
||||
it('records retry-safe events and reports whether the event was inserted', async () => {
|
||||
@@ -69,12 +95,44 @@ describe('DbService v2 methods', () => {
|
||||
expect(values).toEqual([
|
||||
'session-1', 'user-1',
|
||||
]);
|
||||
expect(clientQuery.mock.calls).toHaveLength(4);
|
||||
expect(clientQuery.mock.calls).toHaveLength(5);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
|
||||
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
|
||||
expect.stringContaining('UPDATE vibe_sessions')
|
||||
);
|
||||
});
|
||||
|
||||
it('projects material feedback once with the durable event transaction', 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: 'completed', occurred_at: new Date(),
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
};
|
||||
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
|
||||
.mockResolvedValueOnce({ rows: [event] }) // event insert
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
|
||||
.mockResolvedValueOnce({ rows: [] }) // play history
|
||||
.mockResolvedValueOnce({ rows: [] }) // track counter
|
||||
.mockResolvedValueOnce({ rows: [] }) // session timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
|
||||
type: 'completed', trackId: 'track-1',
|
||||
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
|
||||
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('vibe_event_projections'),
|
||||
expect.stringContaining('INSERT INTO play_history'),
|
||||
]));
|
||||
expect(evidence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an event when no owned session is returned', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
@@ -114,9 +172,116 @@ describe('DbService v2 methods', () => {
|
||||
expect.stringContaining('INSERT INTO vibe_events')
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
const ended = { ...active, status: 'ended', ended_at: new Date() };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [active] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [ended] }) // status transition
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: ended, ended: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
|
||||
});
|
||||
|
||||
it('resumes an owned session once and records session_resumed in the same lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // no old resume event
|
||||
.mockResolvedValueOnce({ rows: [active] }) // activate
|
||||
.mockResolvedValueOnce({ rows: [] }) // ledger event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.resumeVibeSession('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: active, resumed: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable Vibe plans', () => {
|
||||
it('publishes a revision and its plan_published event in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockResolvedValueOnce({ rows: [] }) // plan_published
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('rolls back the plan header and items if writing plan_published fails', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockRejectedValueOnce(new Error('ledger write failed'))
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).rejects.toThrow('ledger write failed');
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
|
||||
});
|
||||
|
||||
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
|
||||
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
|
||||
});
|
||||
|
||||
it('reads every durable session track as a replacement-plan exclusion', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
|
||||
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
|
||||
.resolves.toEqual(['served', 'skipped', 'disliked']);
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
|
||||
});
|
||||
|
||||
it('writes a header and all items in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
@@ -143,6 +308,59 @@ describe('DbService v2 methods', () => {
|
||||
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('serves and commits one next item under the session lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [item] }) // commit item
|
||||
.mockResolvedValueOnce({ rows: [] }) // track_served event
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
|
||||
});
|
||||
|
||||
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item: null, stale: true });
|
||||
expect(clientQuery.mock.calls).toHaveLength(4);
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('returns the original item when a version-aware next request is retried', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [item] }) // prior served item
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('reads the latest revision and reconstructs ordered plan items', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{
|
||||
|
||||
Reference in New Issue
Block a user