mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 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
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
interface ProfileOperationRecord extends QueryResultRow {
|
||||
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
|
||||
|
||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||
@@ -139,9 +140,10 @@ export interface AdminRepository {
|
||||
|
||||
export class PostgresAdminRepository implements AdminRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string, private readonly options: {
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection, private readonly options: {
|
||||
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
||||
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async principal(userId: string): Promise<AdminPrincipal> {
|
||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
||||
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
|
||||
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
||||
|
||||
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||
@@ -44,7 +45,7 @@ export interface AnswersRepository {
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<CoupleStreakView | null>;
|
||||
@@ -53,8 +54,9 @@ export interface AnswersRepository {
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
private zones: Promise<Set<string>> | null = null;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||
@@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading one row past the page decides `hasMore` exactly, and the total — which
|
||||
* the client only records when it refreshes — is counted on the first page only
|
||||
* instead of on every scroll. Unread is aggregated once per couple rather than as
|
||||
* a correlated count per row: the sort key depends on it, so the old subquery
|
||||
* already ran for every candidate match, not just the twenty that were returned.
|
||||
*/
|
||||
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
||||
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
|
||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
const params=[userId,ctx.couple_id,archived,limit+1,offset];
|
||||
const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`;
|
||||
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
|
||||
const [rows,count]=await Promise.all([
|
||||
this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
|
||||
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
|
||||
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
|
||||
}
|
||||
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
||||
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
||||
@@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
}
|
||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
||||
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
||||
async close(){await this.pool.end();}
|
||||
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export interface PackContext { id: string; name: string; icon: string | null }
|
||||
export interface AppQuestion {
|
||||
@@ -19,6 +20,50 @@ export interface StrokeSegment {
|
||||
}
|
||||
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
||||
|
||||
/**
|
||||
* A change summary rather than a payload. The mobile app used to re-fetch profile,
|
||||
* couple, matches, both pending directions and enabled packs every five seconds to
|
||||
* notice partner activity; it now compares this summary with the last one it saw and
|
||||
* refreshes only the domains whose marker actually moved. Every field is a marker,
|
||||
* not display data, so the response stays a few hundred bytes whatever the couple's
|
||||
* history looks like.
|
||||
*/
|
||||
export interface SyncSummary {
|
||||
server_time: string;
|
||||
couple_id: string | null;
|
||||
profile_updated_at: string | null;
|
||||
partner_id: string | null;
|
||||
partner_updated_at: string | null;
|
||||
/** Matches visible to this member: archiving one of them moves the count. */
|
||||
match_count: number;
|
||||
new_match_count: number;
|
||||
latest_match_at: string | null;
|
||||
/** Partner answered, this member has not. */
|
||||
pending_yours: number;
|
||||
/** This member answered, partner has not. */
|
||||
pending_theirs: number;
|
||||
unread_total: number;
|
||||
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
|
||||
enabled_packs_fingerprint: string;
|
||||
/**
|
||||
* Digest of each visible match's type and response summary. `match_count` /
|
||||
* `new_match_count` / `latest_match_at` only move when a match is created,
|
||||
* archived or unarchived — not when a partner edits an existing response and
|
||||
* changes its `match_type` or `response_summary` — so this fingerprint is
|
||||
* what actually notices that edit.
|
||||
*/
|
||||
match_state_fingerprint: string;
|
||||
/**
|
||||
* Digest of each visible match's unread count. `unread_total` can stay equal
|
||||
* while unread messages redistribute between matches (one read, another
|
||||
* receives a message), which reorders the unread-first list without moving
|
||||
* the total; this fingerprint is what notices that.
|
||||
*/
|
||||
match_unread_fingerprint: string;
|
||||
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
|
||||
streak_updated_at: string | null;
|
||||
}
|
||||
|
||||
export class AppDataError extends Error {
|
||||
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
||||
}
|
||||
@@ -31,6 +76,7 @@ export interface AppDataRepository {
|
||||
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
||||
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
||||
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||
syncSummary(userId: string): Promise<SyncSummary>;
|
||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||
close(): Promise<void>;
|
||||
@@ -38,11 +84,22 @@ export interface AppDataRepository {
|
||||
|
||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
||||
|
||||
interface SyncRow extends QueryResultRow {
|
||||
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
|
||||
partner_id: string | null; partner_updated_at: Date | null;
|
||||
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
|
||||
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
|
||||
enabled_packs_fingerprint: string | null;
|
||||
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
|
||||
streak_updated_at: Date | null;
|
||||
}
|
||||
|
||||
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||
|
||||
export class PostgresAppDataRepository implements AppDataRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||
const result = await this.pool.query<PackContext>(`
|
||||
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
|
||||
}
|
||||
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||
}
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
/**
|
||||
* One round trip, all scalar subqueries, nothing that grows with the couple's
|
||||
* history. An unpaired member's couple-scoped markers all collapse to zero or null
|
||||
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
|
||||
*/
|
||||
async syncSummary(userId: string): Promise<SyncSummary> {
|
||||
const result = await this.pool.query<SyncRow>(`
|
||||
with me as (select id, couple_id, updated_at from profiles where id = $1),
|
||||
visible as (
|
||||
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
|
||||
where m.couple_id = me.couple_id
|
||||
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
|
||||
),
|
||||
visible_unread as (
|
||||
select v.id as match_id, count(msg.id)::int as c
|
||||
from visible v
|
||||
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
|
||||
and msg.read_at is null and msg.deleted_at is null
|
||||
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
|
||||
group by v.id
|
||||
)
|
||||
select
|
||||
now() as server_time,
|
||||
me.couple_id,
|
||||
me.updated_at as profile_updated_at,
|
||||
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
|
||||
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
|
||||
(select count(*)::int from visible) as match_count,
|
||||
(select count(*)::int from visible where is_new) as new_match_count,
|
||||
(select max(created_at) from visible) as latest_match_at,
|
||||
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
|
||||
and not exists (select 1 from responses o
|
||||
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
|
||||
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
|
||||
and not exists (select 1 from responses o
|
||||
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
|
||||
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
|
||||
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
|
||||
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
|
||||
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
|
||||
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
|
||||
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
|
||||
from visible v) as match_state_fingerprint,
|
||||
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
|
||||
from visible_unread vu) as match_unread_fingerprint,
|
||||
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
|
||||
from me`, [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
|
||||
return {
|
||||
server_time: row.server_time.toISOString(),
|
||||
couple_id: row.couple_id,
|
||||
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
|
||||
partner_id: row.partner_id,
|
||||
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
|
||||
match_count: row.match_count ?? 0,
|
||||
new_match_count: row.new_match_count ?? 0,
|
||||
latest_match_at: row.latest_match_at?.toISOString() ?? null,
|
||||
pending_yours: row.pending_yours ?? 0,
|
||||
pending_theirs: row.pending_theirs ?? 0,
|
||||
unread_total: row.unread_total ?? 0,
|
||||
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
|
||||
match_state_fingerprint: row.match_state_fingerprint ?? '',
|
||||
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
|
||||
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
|
||||
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
// The client polls this instead of re-fetching every domain; see SyncSummary.
|
||||
app.get('/v1/me/sync', async c => {
|
||||
const result = await run(() => repository.syncSummary(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||
app.post('/v1/me/nudge', async c => {
|
||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
|
||||
|
||||
export class PostgresBillingRepository implements BillingRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async processRevenueCatEvent(
|
||||
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export type ChatMediaType = 'image' | 'video' | null;
|
||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||
@@ -45,8 +46,37 @@ export class ChatError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply to a message poll. A request without a cursor is a full snapshot; a
|
||||
* request with one carries only what changed, so a quiet chat costs an empty
|
||||
* array rather than the whole backlog.
|
||||
*/
|
||||
export interface MessagesPage {
|
||||
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
|
||||
messages: ChatMessage[];
|
||||
/** Messages the caller deleted for themselves since the cursor. */
|
||||
removed_ids: string[];
|
||||
/** Cursor to send as `since` on the next poll. */
|
||||
server_time: string;
|
||||
/** True when `messages` is a complete snapshot the client may replace its cache with. */
|
||||
complete: boolean;
|
||||
/** Present only when the caller asked for it, folding the typing poll into this one. */
|
||||
typing?: PartnerTyping;
|
||||
}
|
||||
|
||||
export interface PartnerTyping {
|
||||
typing: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
|
||||
since?: string;
|
||||
includeTyping?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatRepository {
|
||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
||||
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
|
||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||
@@ -55,13 +85,21 @@ export interface ChatRepository {
|
||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// `media_expired` is derived rather than read. Nothing ever writes the stored
|
||||
// column — it is inserted `false` and never updated — so a video that reached
|
||||
// `media_expires_at` still arrived flagged as playable, while the media itself
|
||||
// was already refused (see `accessible` in the media repository, which treats
|
||||
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
|
||||
// same rule the media side already applies, and it is what makes the client's
|
||||
// "Video expired" state reachable at all.
|
||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
||||
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
|
||||
m.media_viewed_at, m.version, m.encrypted_content,
|
||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
|
||||
|
||||
export class PostgresChatRepository implements ChatRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return row;
|
||||
}
|
||||
|
||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
||||
/**
|
||||
* Without `since` this is the previous behaviour: the newest 500 visible messages.
|
||||
* With `since` it returns only rows whose visible state moved after that instant —
|
||||
* new messages, existing ones whose delivery, read, deletion or media-view
|
||||
* timestamp advanced, and videos whose media expired inside the window — plus the
|
||||
* ids the caller has since deleted for themselves.
|
||||
*
|
||||
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
|
||||
* covered: the classifier and the admin tool rewrite those columns without
|
||||
* advancing any timestamp, so a delta cannot see them move. No client renders
|
||||
* them today, so nothing user-visible goes stale; giving them a cursor would
|
||||
* mean adding a column to `messages`.
|
||||
* Rows are returned whole rather than as patches so the client merges by id and
|
||||
* needs no second shape to interpret.
|
||||
*
|
||||
* The returned cursor is deliberately a couple of seconds behind the server clock:
|
||||
* a transaction that started before this read but committed after it would
|
||||
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
|
||||
* of rows the client already has, which its merge-by-id absorbs.
|
||||
*/
|
||||
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
||||
)
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
[matchId, userId],
|
||||
);
|
||||
return result.rows.map(toMessage);
|
||||
const since = options.since ?? null;
|
||||
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||
// Expiry is the one visible change no row write announces: the clock crosses
|
||||
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||
// cursor. Comparing the deadline against the window instead emits the row in
|
||||
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||
// the derived `media_expired` above flips.
|
||||
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||
|
||||
const [clock, messages, removed, typing] = await Promise.all([
|
||||
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||
this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
since === null ? [matchId, userId] : [matchId, userId, since],
|
||||
),
|
||||
since === null
|
||||
? null
|
||||
: this.pool.query<{ message_id: string }>(
|
||||
`select d.message_id
|
||||
from message_deletions d
|
||||
join messages m on m.id = d.message_id
|
||||
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
|
||||
[matchId, userId, since],
|
||||
),
|
||||
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: messages.rows.map(toMessage),
|
||||
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
|
||||
server_time: clock.rows[0]!.cursor.toISOString(),
|
||||
complete: since === null,
|
||||
...(typing ? { typing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
/** Access already asserted by the caller, so the message poll can fold this in for free. */
|
||||
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
const result = await this.pool.query<{ expires_at: Date }>(
|
||||
`select expires_at from chat_typing_states
|
||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
return this.partnerTyping(userId, matchId);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const id = z.string().uuid();
|
||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
||||
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
||||
const since = z.string().datetime({ offset: true });
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
|
||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
||||
|
||||
// `since` turns this into a delta poll and `typing=true` folds the typing check
|
||||
// into the same request. Both are optional: a call with neither still answers with
|
||||
// the full snapshot under the `messages` key it always used.
|
||||
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
const rawSince = c.req.query('since');
|
||||
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
|
||||
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
|
||||
...(parsedSince?.success ? { since: parsedSince.data } : {}),
|
||||
includeTyping: c.req.query('typing') === 'true',
|
||||
}));
|
||||
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Couple, Profile } from '@sauci/shared';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
||||
|
||||
export class PostgresCoupleRepository implements CoupleRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async getState(userId: string): Promise<CoupleStateResponse> {
|
||||
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export type DareStatus =
|
||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||
@@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||
|
||||
export class PostgresDaresRepository implements DaresRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
||||
|
||||
interface MediaRow extends QueryResultRow {
|
||||
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
|
||||
|
||||
export class PostgresMediaRepository implements MediaRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
||||
const client = await this.pool.connect();
|
||||
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
|
||||
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
||||
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
||||
|
||||
interface OutboxRow extends QueryResultRow {
|
||||
@@ -31,7 +32,8 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
||||
|
||||
export class PostgresOperationsRepository implements OperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
@@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
||||
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
|
||||
function streakCopy(days:number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export interface PackCategory {
|
||||
id: string;
|
||||
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
|
||||
|
||||
export class PostgresPacksRepository implements PacksRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
||||
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type {
|
||||
FeedbackSubmission,
|
||||
NotificationPreferenceKey,
|
||||
@@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
|
||||
|
||||
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
||||
@@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user