mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware. Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
1 parent
9957fa43f2
commit
c7ee8a4851
81 files changed
+4456
-1400
No files matched your search
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'
|
||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||
@@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn
|
||||
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
|
||||
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
|
||||
|
||||
describe('match pagination',()=>{
|
||||
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
|
||||
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
|
||||
const otherMatch='e0000000-0000-4000-8000-000000000000';
|
||||
beforeAll(async()=>{
|
||||
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
|
||||
// One question per match: a couple may only match a given question once.
|
||||
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
|
||||
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
|
||||
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
|
||||
// A match belonging to another couple must never appear, whatever the page.
|
||||
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
|
||||
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
|
||||
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
|
||||
});
|
||||
|
||||
it('counts once on the first page, never again, and still reports hasMore',async()=>{
|
||||
const first=await repo.matches(u1,0,2,false);
|
||||
expect(first.totalCount).toBe(5);
|
||||
expect(first.hasMore).toBe(true);
|
||||
expect(first.matches).toHaveLength(2);
|
||||
|
||||
// Later pages skip the count entirely: the client already has it and paging
|
||||
// is decided by hasMore, which comes from reading limit + 1 rows.
|
||||
const second=await repo.matches(u1,1,2,false);
|
||||
expect(second.totalCount).toBeNull();
|
||||
expect(second.hasMore).toBe(true);
|
||||
const last=await repo.matches(u1,2,2,false);
|
||||
expect(last.totalCount).toBeNull();
|
||||
expect(last.hasMore).toBe(false);
|
||||
expect(last.matches).toHaveLength(1);
|
||||
|
||||
// An exactly-full final page must not claim another page exists.
|
||||
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
|
||||
});
|
||||
|
||||
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
|
||||
const page=await repo.matches(u1,0,5,false);
|
||||
// The unread match sorts ahead of newer ones; the rest stay newest-first.
|
||||
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
|
||||
expect(page.matches[0]).toMatchObject({unreadCount:2});
|
||||
// Our own message is not unread to us, and everything else is zero.
|
||||
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
|
||||
// Unread is per viewer: the partner's own two messages are not unread to them,
|
||||
// but ours is, so the same thread reports a different count on each side.
|
||||
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
|
||||
// The other couple's match is invisible here, and their member sees only theirs.
|
||||
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
|
||||
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
|
||||
});
|
||||
|
||||
it('pages the archived view separately and per user',async()=>{
|
||||
await repo.archive(u1,paged[0],true);
|
||||
const active=await repo.matches(u1,0,5,false);
|
||||
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
|
||||
expect(active.totalCount).toBe(4);
|
||||
const archived=await repo.matches(u1,0,5,true);
|
||||
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
|
||||
expect(archived).toMatchObject({totalCount:1,hasMore:false});
|
||||
// Archiving is per user, so the partner still sees it in their active list.
|
||||
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
|
||||
await repo.archive(u1,paged[0],false);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
|
||||
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
|
||||
await repo.submit(u1,{questionId:q,answer:'yes'});
|
||||
|
||||
Reference in new issue
Block a user